Same table. New state. New statement.
Most tables are not copied at every step; they are updated where they sit. This page runs real SQL against one SQLite table, twice, and attests every state the table passes through. The database keeps only the latest rows. Makoto keeps every state as its own digest, each statement naming the one before it.
Step through this in the Explorer. It follows the same three customer records through the new-file version of this lifecycle, one stage at a time.
Every value on this page is generated. The commands are committed files, scripts/in_place_example.py runs them against a fresh SQLite database, and every export, digest and statement below comes from that run and validates against the published schemas. If your pipeline writes a new file at every step instead, the lifecycle walkthrough follows that shape.
customers.dbtable customers, rewritten in place
Holds one state at a time.
-
INSERT INTO customers (customer_id, email, marketing_consent, region, age) VALUES ('C-1001', ' [email protected] ', ' YES ', 'us', 34), ('C-1002', '[email protected]', 'false', 'gb', 67), ('C-1003', ' [email protected]', '1', 'ca', 17);Statement 1 · origin- State 1
4e90181e…- Statement
a1584131…
-
UPDATE customers SET email = lower(trim(email)), marketing_consent = CASE lower(trim(marketing_consent)) WHEN 'yes' THEN 1 WHEN 'true' THEN 1 WHEN '1' THEN 1 WHEN 'no' THEN 0 WHEN 'false' THEN 0 WHEN '0' THEN 0 END, region = upper(region);Statement 2 · transformation- State 2
1985f7b3…- Statement
8444407e…
-
UPDATE customers SET marketing_consent = 0 WHERE age < 18 AND marketing_consent = 1;Statement 3 · transformation- State 3
86c8be84…- Statement
f57f35ea…
The table and what gets hashed
One table, created once. The consent column has no declared type, so SQLite stores whatever each command writes into it: text on the way in, integers once normalized.
-- marketing_consent is declared without a type, so SQLite stores each value -- exactly as written: text when loaded, integers after normalization. CREATE TABLE customers ( customer_id TEXT PRIMARY KEY, email TEXT, marketing_consent, region TEXT, age INTEGER );A table is not a file, and the database file is the wrong thing to hash. Two SQLite files holding identical rows can differ byte for byte: page layout, free pages and journal state all vary. The specification hashes exact bytes and leaves it to the producer to make serialization and row order deterministic, so this walkthrough defines one export and attests its output:
SELECT json_group_array(json_object( 'age', age, 'customer_id', customer_id, 'email', email, 'marketing_consent', marketing_consent, 'region', region )) FROM (SELECT * FROM customers ORDER BY customer_id);The attested bytes are exactly what that query prints: one JSON array, rows ordered by
customer_id, keys in alphabetical order, no whitespace, followed by a single line feed.sqlite3 customers.db < export.sqlproduces those bytes, so anyone with the sqlite3 shell can re-hash a state. The generator also serializes the rows independently and fails if the two ever disagree.The data
Load the table and attest it
The first command loads three partner records with the usual intake defects: padded whitespace, mixed case, and three spellings of a consent answer.
INSERT INTO customers (customer_id, email, marketing_consent, region, age) VALUES ('C-1001', ' [email protected] ', ' YES ', 'us', 34), ('C-1002', '[email protected]', 'false', 'gb', 67), ('C-1003', ' [email protected]', '1', 'ca', 17);Exported, the table is state 1:
[{"age":34,"customer_id":"C-1001","email":" [email protected] ","marketing_consent":" YES ","region":"us"},{"age":67,"customer_id":"C-1002","email":"[email protected]","marketing_consent":"false","region":"gb"},{"age":17,"customer_id":"C-1003","email":" [email protected]","marketing_consent":"1","region":"ca"}]That digest is the same one the lifecycle walkthrough starts from: the export is byte-for-byte the raw customer file used there. The records are the same; only what happens to them next differs.
What Makoto adds
Attest the loaded state
An origin statement attests state 1. The extension block records which database, table, commands and export produced the subject, because none of that is visible in the digest itself.
{ "_type": "https://in-toto.io/Statement/v1", "subject": [ { "name": "customers.db#customers", "digest": { "sha256": "4e90181e7f75ccdf0e4d81c83d281f53806bed241787cdc751e22b2d4cd19919" } } ], "predicateType": "https://usemakoto.dev/predicate/v0.2/origin", "predicate": { "schemaVersion": "0.2", "event": { "id": "urn:uuid:110c8a8a-aabf-5155-a37d-df55f64e0e29", "occurredAt": "2026-09-16T16:00:00Z" }, "source": { "kind": "urn:makoto:example:source:sqlite-table", "name": "customers table, initial load", "mediaType": "application/json", "retrievedAt": "2026-09-16T16:00:00Z" }, "profiles": [], "extensions": { "urn:makoto:example:in-place-table": { "database": "customers.db", "table": "customers", "tableDefinition": "sql/00-create.sql", "command": "sql/01-load.sql", "export": "sql/export.sql", "exportDigest": { "sha256": "8513ac471e80707fab7bc153743ecfab5777a3c73e651e813223c527cac89aa2" }, "storageKeepsPreviousState": false } } } }The data
Edit it in place
Same table, same file. State 1 is gone from storage.
One
UPDATEnormalizes every row where it sits: whitespace trimmed, email folded to lower case, consent resolved to 1 or 0, region upper-cased.UPDATE customers SET email = lower(trim(email)), marketing_consent = CASE lower(trim(marketing_consent)) WHEN 'yes' THEN 1 WHEN 'true' THEN 1 WHEN '1' THEN 1 WHEN 'no' THEN 0 WHEN 'false' THEN 0 WHEN '0' THEN 0 END, region = upper(region);[{"age":34,"customer_id":"C-1001","email":"[email protected]","marketing_consent":1,"region":"US"},{"age":67,"customer_id":"C-1002","email":"[email protected]","marketing_consent":0,"region":"GB"},{"age":17,"customer_id":"C-1003","email":"[email protected]","marketing_consent":1,"region":"CA"}]Before and after the normalize command, for customer C-1001.Column Before After What happened email" [email protected] ""[email protected]"Whitespace trimmed, case folded. marketing_consent" YES "1Text answer resolved to an integer. region"us""US"Upper-cased. age3434Unchanged. customer_id"C-1001""C-1001"Unchanged. Nothing in the database remembers state 1 now. Querying the table cannot recover those rows and cannot prove they existed. The only surviving record of state 1 is its digest, inside statement 1.
What Makoto adds
Attest the new state
A transformation statement attests state 2, and its
inputsblock ties it to the state it replaced.{ "_type": "https://in-toto.io/Statement/v1", "subject": [ { "name": "customers.db#customers", "digest": { "sha256": "1985f7b32568c7c5740b4008cc9c1212850b13b6bf027ba963cc46b9e67aa982" } } ], "predicateType": "https://usemakoto.dev/predicate/v0.2/transform", "predicate": { "schemaVersion": "0.2", "event": { "id": "urn:uuid:5699b2a6-5282-5270-8f51-6e3b4972eda5", "occurredAt": "2026-09-16T16:05:00Z" }, "operation": { "type": "urn:makoto:example:operation:normalize", "name": "Normalize email, consent and region in place", "tool": { "name": "SQLite" }, "parametersDigest": { "sha256": "a9f53771a9db5a50f78ffffd600f43684ea8bec8b9b347627f4c14d410660b1b" } }, "profiles": [], "extensions": { "urn:makoto:example:in-place-table": { "database": "customers.db", "table": "customers", "tableDefinition": "sql/00-create.sql", "command": "sql/02-normalize.sql", "export": "sql/export.sql", "exportDigest": { "sha256": "8513ac471e80707fab7bc153743ecfab5777a3c73e651e813223c527cac89aa2" }, "storageKeepsPreviousState": false, "rowsChanged": 3 } }, "inputs": [ { "name": "previous-state", "digest": { "sha256": "4e90181e7f75ccdf0e4d81c83d281f53806bed241787cdc751e22b2d4cd19919" }, "provenance": { "statementDigest": { "sha256": "a158413155f40165adb7fa52553fb9008b33189e38ffc82d653d9892a8e2b383" }, "subjectName": "customers.db#customers" } } ] } }The input and the subject share one name,
customers.db#customers, and differ only in digest. That is the in-place pattern in one line: the name says which table, the digest says which state.The data
Edit it again
Same table again. State 2 is gone from storage too.
A policy fix: a customer under 18 cannot give marketing consent. The command touches one row.
UPDATE customers SET marketing_consent = 0 WHERE age < 18 AND marketing_consent = 1;[{"age":34,"customer_id":"C-1001","email":"[email protected]","marketing_consent":1,"region":"US"},{"age":67,"customer_id":"C-1002","email":"[email protected]","marketing_consent":0,"region":"GB"},{"age":17,"customer_id":"C-1003","email":"[email protected]","marketing_consent":0,"region":"CA"}]Before and after the withdrawal, for customer C-1003, the only row it changed.Column Before After What happened email"[email protected]""[email protected]"Unchanged. marketing_consent10Withdrawn: the customer is 17. region"CA""CA"Unchanged. age1717Unchanged. customer_id"C-1003""C-1003"Unchanged. What Makoto adds
Attest it again
The same pattern, one link further along: the input is state 2 and the predecessor is statement 2.
{ "_type": "https://in-toto.io/Statement/v1", "subject": [ { "name": "customers.db#customers", "digest": { "sha256": "86c8be84d5e35cb1a5ebb0fbde14f3bd622f69cc021492ec8ff98456daa8b1d0" } } ], "predicateType": "https://usemakoto.dev/predicate/v0.2/transform", "predicate": { "schemaVersion": "0.2", "event": { "id": "urn:uuid:e789d3ba-79d4-5362-b1a9-bf4fa6df3e84", "occurredAt": "2026-09-16T16:10:00Z" }, "operation": { "type": "urn:makoto:example:operation:withdraw-minor-consent", "name": "Withdraw consent held by minors", "tool": { "name": "SQLite" }, "parametersDigest": { "sha256": "181098ef88909bae3dc238a10be2cc630907584eed6fd57330dd65bcc25c5a79" } }, "profiles": [], "extensions": { "urn:makoto:example:in-place-table": { "database": "customers.db", "table": "customers", "tableDefinition": "sql/00-create.sql", "command": "sql/03-withdraw-minor-consent.sql", "export": "sql/export.sql", "exportDigest": { "sha256": "8513ac471e80707fab7bc153743ecfab5777a3c73e651e813223c527cac89aa2" }, "storageKeepsPreviousState": false, "rowsChanged": 1 } }, "inputs": [ { "name": "previous-state", "digest": { "sha256": "1985f7b32568c7c5740b4008cc9c1212850b13b6bf027ba963cc46b9e67aa982" }, "provenance": { "statementDigest": { "sha256": "8444407e871286f68eef9aaee6cbbc1d76eff5fea18d311d74d89e425d841c63" }, "subjectName": "customers.db#customers" } } ] } }What storage keeps, and what Makoto keeps
After both edits. Only state 3 is still in the database; statements name all three. State In the database Export digest Statement digest Declares its predecessor as 1 · loaded No 4e90181e7f75ccdf0e4d81c83d281f53806bed241787cdc751e22b2d4cd19919a158413155f40165adb7fa52553fb9008b33189e38ffc82d653d9892a8e2b383None: the root of the chain 2 · normalized No 1985f7b32568c7c5740b4008cc9c1212850b13b6bf027ba963cc46b9e67aa9828444407e871286f68eef9aaee6cbbc1d76eff5fea18d311d74d89e425d841c63a158413155f40165adb7fa52553fb9008b33189e38ffc82d653d9892a8e2b3833 · consent withdrawn Yes 86c8be84d5e35cb1a5ebb0fbde14f3bd622f69cc021492ec8ff98456daa8b1d0f57f35ea803045407dc5a12a15f250dd5ac31a420bddd48a89e9bafaa68417468444407e871286f68eef9aaee6cbbc1d76eff5fea18d311d74d89e425d841c63A receiver holding the database and the three statements can re-run the export and compare it to statement 3's subject. It can hash each statement and follow the chain back to the origin. And for each step it can check that the input digest equals the previous statement's subject, which is what shows that nothing else wrote to the table between the two commands the statements describe.
What it cannot do is re-hash state 1 or state 2 from the database. Makoto keeps the identity of every state, not its bytes. If the earlier rows must stay checkable, the exports have to be kept somewhere; this page publishes them alongside the statements for exactly that reason.
As on the lifecycle page, these statements are unsigned, so they carry no authenticity or authorization claim, and nothing here anchors freshness. The runnable proof adds signatures, a receiver trust policy, and a pinned handoff.
Reproduce every digest on this page
State digests need only the sqlite3 shell and shasum. Start in an empty directory; the create command fails if customers.db already exists.
$ base=https://usemakoto.dev/examples/in-place/sql
$ for f in 00-create 01-load 02-normalize 03-withdraw-minor-consent export; do curl -fsSO "$base/$f.sql"; done
$ sqlite3 customers.db < 00-create.sql
$ sqlite3 customers.db < 01-load.sql
$ sqlite3 customers.db < export.sql | shasum -a 256
4e90181e7f75ccdf0e4d81c83d281f53806bed241787cdc751e22b2d4cd19919 -
$ sqlite3 customers.db < 02-normalize.sql
$ sqlite3 customers.db < export.sql | shasum -a 256
1985f7b32568c7c5740b4008cc9c1212850b13b6bf027ba963cc46b9e67aa982 -
$ sqlite3 customers.db < 03-withdraw-minor-consent.sql
$ sqlite3 customers.db < export.sql | shasum -a 256
86c8be84d5e35cb1a5ebb0fbde14f3bd622f69cc021492ec8ff98456daa8b1d0 -
$ shasum -a 256 02-normalize.sql 03-withdraw-minor-consent.sql export.sql
a9f53771a9db5a50f78ffffd600f43684ea8bec8b9b347627f4c14d410660b1b 02-normalize.sql
181098ef88909bae3dc238a10be2cc630907584eed6fd57330dd65bcc25c5a79 03-withdraw-minor-consent.sql
8513ac471e80707fab7bc153743ecfab5777a3c73e651e813223c527cac89aa2 export.sql
Statement digests are taken over the canonical serialization, keys sorted with no insignificant whitespace, not over the pretty-printed files:
python3 - <<'EOF'
import hashlib, json, urllib.request
BASE = "https://usemakoto.dev/examples/in-place/attestations/"
for name in ("state-1-origin", "state-2-normalize", "state-3-withdraw-minor-consent"):
raw = urllib.request.urlopen(BASE + name + ".json").read()
canonical = json.dumps(json.loads(raw), sort_keys=True, separators=(",", ":"))
print(hashlib.sha256(canonical.encode()).hexdigest(), name)
EOF
That prints the three statement digests above, in order. From a checkout of this site, one command regenerates every export and statement, validates each statement against the schemas, and fails if a committed file has drifted:
$ uv run scripts/in_place_example.py --check
Two shapes, one rule: new bytes need a new statement.
The lifecycle walkthrough writes a new file at every step. This one rewrites a single table. Either way, each state gets its own digest and each statement names the one before it.