Makoto
Menu
Worked walkthrough · one table, edited in place

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.

Three SQL commands write into one table. After each, the table is exported, the export is hashed, and a statement attests that digest; each later statement names the statement before it. The database only ever holds one state. The earlier states survive as digests inside the statements.

customers.dbtable customers, rewritten in place

Holds one state at a time.

  1. 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…
    No predecessor. The chain starts here.
  2. 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…
    Input: the state 1 digest. Predecessor: statement 1.
  3. UPDATE customers
    SET marketing_consent = 0
    WHERE age < 18 AND marketing_consent = 1;
    Statement 3 · transformation
    State 3
    86c8be84…
    Statement
    f57f35ea…
    Input: the state 2 digest. Predecessor: statement 2.
  1. 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.sql produces 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.

    Export query
    export.sql
    8513ac471e80707fab7bc153743ecfab5777a3c73e651e813223c527cac89aa2
    Subject name
    customers.db#customers, the same in every statement
  2. 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"}]
    State 1
    state-1.json, 311 bytes
    4e90181e7f75ccdf0e4d81c83d281f53806bed241787cdc751e22b2d4cd19919

    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.

  3. 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
          }
        }
      }
    }
    Subject digest
    4e90181e7f75ccdf0e4d81c83d281f53806bed241787cdc751e22b2d4cd19919
    Statement digest
    a158413155f40165adb7fa52553fb9008b33189e38ffc82d653d9892a8e2b383
    Document
    state-1-origin.json
  4. The data

    Edit it in place

    Same table, same file. State 1 is gone from storage.

    One UPDATE normalizes 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.
    ColumnBeforeAfterWhat 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.
    State before
    4e90181e7f75ccdf0e4d81c83d281f53806bed241787cdc751e22b2d4cd19919
    State after
    state-2.json, 294 bytes
    1985f7b32568c7c5740b4008cc9c1212850b13b6bf027ba963cc46b9e67aa982

    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.

  5. What Makoto adds

    Attest the new state

    A transformation statement attests state 2, and its inputs block 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"
            }
          }
        ]
      }
    }
    Input digest
    4e90181e7f75ccdf0e4d81c83d281f53806bed241787cdc751e22b2d4cd19919
    inputs[0].digest: state 1, the rows the command rewrote.
    Predecessor
    a158413155f40165adb7fa52553fb9008b33189e38ffc82d653d9892a8e2b383
    inputs[0].provenance.statementDigest: statement 1.
    Parameters
    a9f53771a9db5a50f78ffffd600f43684ea8bec8b9b347627f4c14d410660b1b
    operation.parametersDigest: the SHA-256 of 02-normalize.sql, so the statement names the exact command that edited the table.

    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.

    Subject digest
    1985f7b32568c7c5740b4008cc9c1212850b13b6bf027ba963cc46b9e67aa982
    Statement digest
    8444407e871286f68eef9aaee6cbbc1d76eff5fea18d311d74d89e425d841c63
    Document
    state-2-normalize.json
  6. 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.
    ColumnBeforeAfterWhat 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.
    State before
    1985f7b32568c7c5740b4008cc9c1212850b13b6bf027ba963cc46b9e67aa982
    State after
    state-3.json, 294 bytes
    86c8be84d5e35cb1a5ebb0fbde14f3bd622f69cc021492ec8ff98456daa8b1d0
  7. 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"
            }
          }
        ]
      }
    }
    Subject digest
    86c8be84d5e35cb1a5ebb0fbde14f3bd622f69cc021492ec8ff98456daa8b1d0
    Statement digest
    f57f35ea803045407dc5a12a15f250dd5ac31a420bddd48a89e9bafaa6841746
    Points back to
    8444407e871286f68eef9aaee6cbbc1d76eff5fea18d311d74d89e425d841c63
    Parameters
    181098ef88909bae3dc238a10be2cc630907584eed6fd57330dd65bcc25c5a79
    Document
    state-3-withdraw-minor-consent.json
  8. What storage keeps, and what Makoto keeps

    After both edits. Only state 3 is still in the database; statements name all three.
    StateIn the databaseExport digestStatement digestDeclares its predecessor as
    1 · loadedNo4e90181e7f75ccdf0e4d81c83d281f53806bed241787cdc751e22b2d4cd19919a158413155f40165adb7fa52553fb9008b33189e38ffc82d653d9892a8e2b383None: the root of the chain
    2 · normalizedNo1985f7b32568c7c5740b4008cc9c1212850b13b6bf027ba963cc46b9e67aa9828444407e871286f68eef9aaee6cbbc1d76eff5fea18d311d74d89e425d841c63a158413155f40165adb7fa52553fb9008b33189e38ffc82d653d9892a8e2b383
    3 · consent withdrawnYes86c8be84d5e35cb1a5ebb0fbde14f3bd622f69cc021492ec8ff98456daa8b1d0f57f35ea803045407dc5a12a15f250dd5ac31a420bddd48a89e9bafaa68417468444407e871286f68eef9aaee6cbbc1d76eff5fea18d311d74d89e425d841c63

    A 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.