Most projects do not begin with a knowledge store. They begin with tables: a directory of people, a list of organizations, and a table that connects them. This guide starts there. It creates an empty in-memory store, rejects a broken relationship before writing anything, accepts corrected records, and preserves a later update as a second revision.
Load a table contract
The package includes a small team-directory contract written with data-dict. This guide uses
its resolved JSON form, so the runnable lesson needs only R and graft.
In a real authoring workflow, write the YAML, validate it with the
data-dict CLI, and run data-dict export-spec once. The
human-authored YAML is included beside the resolved file.
library(graft)
dictionary_path <- system.file(
"extdata",
"team-directory.data-dict.json",
package = "graft",
mustWork = TRUE
)
schema <- graft_schema(dictionary_path)
schema@name
#> [1] "team_directory"
names(schema@classes)
#> [1] "organization" "person" "employment"The three class names are the three table names:
organization, person, and
employment. The contract defines required columns, stable
string IDs, and the two foreign keys in employment. IDs are
strings chosen by the caller and must be globally unique within the
store; the person: and org: prefixes are
readable conventions used by this example. The @ operator
reads public properties from graft’s immutable S7 contract and plan
objects.
Create an empty store
graft_open() creates and initializes the database when
it does not already exist. Here the database lives only in memory and
starts with no accepted records.
store <- graft_open(
schema,
path = ":memory:",
okf = "disabled"
)Use a DuckDB filename instead of ":memory:" when the
knowledge should persist between R sessions.
okf = "disabled" leaves the optional readable file
projection off so this first lesson stays focused on the store and its
history.
Catch a missing relationship before writing
Suppose an import contains Lois Lane and an employment row, but the referenced organization is absent.
invalid_records <- list(
person = data.frame(
id = "person:lois-lane",
full_name = "Lois Lane",
job_title = "Reporter"
),
employment = data.frame(
id = "employment:lois-lane:daily-planet",
person_id = "person:lois-lane",
organization_id = "org:missing"
)
)
invalid_plan <- graft_plan(
store,
invalid_records,
graft_provenance(
producer = "directory-import",
idempotency_key = "directory-invalid"
)
)
invalid_plan@valid
#> [1] FALSE
invalid_plan@issues[
, c("class", "field", "rule", "message"),
drop = FALSE
]
#> class field rule
#> 1 employment organization_id reference_exists
#> message
#> 1 Reference target `org:missing` does not exist.Planning is read-only. The missing organization appears as a review
issue, and neither the person nor the employment row has been accepted.
producer names the source workflow. An
idempotency_key names one retryable source event so
repeating the same accepted event does not create another revision.
Correct and review the candidate set
Add the organization and plan all three tables together. These are ordinary data frames; the list names tell graft which contract table each frame uses.
organizations <- data.frame(
id = "org:daily-planet",
name = "Daily Planet"
)
people <- data.frame(
id = "person:lois-lane",
full_name = "Lois Lane",
job_title = "Reporter"
)
employment <- data.frame(
id = "employment:lois-lane:daily-planet",
person_id = "person:lois-lane",
organization_id = "org:daily-planet"
)
initial_records <- list(
organization = organizations,
person = people,
employment = employment
)
initial_plan <- graft_plan(
store,
initial_records,
graft_provenance(
producer = "directory-import",
run_id = "import-2026-08-01",
idempotency_key = "directory-2026-08-01"
)
)
initial_plan@changes[
, c("class", "record_id", "action"),
drop = FALSE
]
#> class record_id action
#> 1 organization org:daily-planet insert
#> 2 person person:lois-lane insert
#> 3 employment employment:lois-lane:daily-planet insert
initial_plan@issues
#> [1] class input_row record_id field
#> [5] rule message condition_class
#> <0 rows> (or 0-length row.names)The organization can satisfy the employment foreign key in the same
candidate set. @valid means the plan can be committed;
@changes shows whether it should be.
Commit the reviewed plan
stopifnot(initial_plan@valid)
initial_result <- graft_commit(store, initial_plan)
initial_result$inserted
#> organization person employment
#> 1 1 1Immediately before writing, graft checks that the plan is intact and still matches the store and active contract. All three records and their provenance commit in one transaction.
Accept a later update
The next directory import changes only Lois’s job title. Planning compares the candidate with the accepted head and reports one update.
update_plan <- graft_plan(
store,
list(
person = data.frame(
id = "person:lois-lane",
full_name = "Lois Lane",
job_title = "Investigative editor"
)
),
graft_provenance(
producer = "hr-review",
run_id = "review-2026-08-08",
idempotency_key = "hr-review-2026-08-08"
)
)
update_plan@changes[
, c("class", "record_id", "action", "changed_fields"),
drop = FALSE
]
#> class record_id action changed_fields
#> 1 person person:lois-lane update job_title
stopifnot(update_plan@valid)
update_result <- graft_commit(store, update_plan)
update_result$updated
#> person
#> 1Read current knowledge and history
graft_get() returns the current accepted record. The
employment record keeps the stable IDs that connect the person to the
organization.
current_person <- graft_get(
store,
"person:lois-lane",
include = character()
)
current_person$record
#> $id
#> [1] "person:lois-lane"
#>
#> $full_name
#> [1] "Lois Lane"
#>
#> $job_title
#> [1] "Investigative editor"
current_employment <- graft_get(
store,
"employment:lois-lane:daily-planet",
include = character()
)$record
current_organization <- graft_get(
store,
current_employment$organization_id,
include = character()
)$record
current_organization
#> $id
#> [1] "org:daily-planet"
#>
#> $name
#> [1] "Daily Planet"graft_history() retains both accepted versions. It
returns newest first.
history <- graft_history(store, "person:lois-lane", limit = 10L)
data.frame(
revision = history$revision_number,
committed_at = history$committed_at,
producer = history$producer,
event = history$source_run_id,
contract = substr(history$schema_build_digest, 1L, 19L),
job_title = vapply(
history$record,
\(record) record$job_title,
character(1)
)
)
#> revision committed_at producer event
#> 1 2 2026-08-17 03:13:34 hr-review review-2026-08-08
#> 2 1 2026-08-17 03:13:33 directory-import import-2026-08-01
#> contract job_title
#> 1 sha256:bf79e47da7f3 Investigative editor
#> 2 sha256:bf79e47da7f3 ReporterThe current record is convenient for applications. The revision history is the audit trail: it retains what was accepted, when, under which contract, and from which producer event. The displayed contract value is an abbreviated build digest; the history result retains the complete digest.
Close the store
graft_close(store)Add richer graph meaning with LinkML
The data-dict contract was enough to validate ordinary tables and their scalar foreign keys. Those foreign keys remain validated references; graft does not turn them into graph traversal edges.
LinkML is the next step when a relationship needs ontology
identifiers, inheritance, polymorphic targets, or explicit semantic
statement and evidence shapes. The bundled materials contract declares
Measurement as a semantic statement, so accepted
entity-valued measurements can become typed traversal edges. This is
deliberately a second domain: the LinkML guide first explains how the
same team-directory concepts map to LinkML, then uses materials to run a
semantic edge that the package already ships.
linkml_schema <- graft_schema(system.file(
"extdata",
"materials.graft.json",
package = "graft",
mustWork = TRUE
))
linkml_schema@classes$Measurement@role
#> [1] "statement"
linkml_schema@classes$Measurement@statement_shape
#> [1] "semantic"Read the data-dict guide to adapt an existing table contract. Next, deepen the package workflow with change control and retrieval and history. Then read the LinkML guide when the domain needs richer semantic structure.