The transaction chain signs caller_principal, captured in handle_preview at crates/sysknife-daemon/src/dispatcher.rs:2106, so a verified row answers which account asked for an action. ChainIdentity::V3 states why the field exists (crates/sysknife-daemon/src/audit_chain.rs:466):
v2 answers "an Admin did this". On a host with two members of sysknife-admin their signed records were indistinguishable, so the trail could not answer the first question an investigation asks.
The approval chain never got the same treatment. EventContent::canonical_bytes signs six fields, and no account appears among them (crates/sysknife-daemon/src/audit_chain.rs:934):
seq, key_id, kind, transaction_id, receipt_digest, created_at
TransactionStore::approve_transaction takes a transaction id and nothing else (crates/sysknife-daemon/src/transactions.rs:548), while handle_approve holds a CallerAttribution and passes it to authorize_for_transaction a few lines earlier (crates/sysknife-daemon/src/dispatcher.rs:1361). claim_approved_for_execution is the same (transactions.rs:652): the account that spends the receipt is never written down.
What it costs
Two admins on one host, alice at uid 1000 and bob at uid 1001, both in sysknife-admin. Alice's agent previews AddAuthorizedKey, bob types sysknife approve <txid>, alice's agent executes. The verified trail then holds:
| record |
what it names |
| transaction row |
caller_principal = uid:1000, alice, who previewed |
approval_granted |
transaction id, receipt digest |
approval_consumed |
transaction id, receipt digest |
Nothing names bob. The receipt exists so that a human authorises what an agent proposed, and the chain cannot say which human that was.
docs/automatic-rollback.md:130 describes the signed fields as "the authorization decision — who approved what, at what risk level". They record who asked.
Why this is more than an ALTER TABLE
transactions versions its signed encoding per row through chain_version, added in migration 2 so rows written by an older binary keep verifying (crates/sysknife-daemon/src/transactions.rs:171). audit_events has no such column in either backend (transactions.rs:179, crates/sysknife-daemon/src/store/postgres.rs:119). Add a field to EventContent without versioning the encoding and every event row already on disk reports Broken, because its signature covers the six-field message.
Suggested shape
- Migration 4 in both
SQLITE_MIGRATIONS and the Postgres MIGRATIONS: audit_events.chain_version defaulting to 1, audit_events.caller_principal nullable, for the reason spelled out in the migration-2 comment.
- An
EventIdentity enum beside ChainIdentity, where LegacyV1 signs today's six fields and V2 appends the principal, with event_message selecting per row.
- Thread
CallerPrincipal into approve_transaction, claim_approved_for_execution and revoke_unconsumed_approval, from the CallerAttribution the handlers already hold.
- Keep
sysknife audit verify correct over a mixed-version event chain.
Acceptance
- An event row written before the change still verifies, against a committed fixture rather than one the test regenerates.
- A grant and a consume by different uids are distinguishable in the chain.
- Editing a stored
caller_principal on an event reports Broken.
- Both backends.
crates/sysknife-daemon/tests/postgres_store.rs:298 is the pattern for the Postgres side.
Difficulty: hard. Two storage backends, a signed encoding, and a compatibility fixture.
Getting started
CONTRIBUTING.md has the build and test commands, docs/architecture.md covers the trust boundary, and docs/the-audit-chain.md explains what the chain signs today. No CLA and no copyright waiver. The project is MIT.
The Postgres half needs a local database. podman run -d -e POSTGRES_USER=sysknife -e POSTGRES_PASSWORD=sysknife -e POSTGRES_DB=sysknife_test -p 5432:5432 docker.io/library/postgres:17-alpine, then run the contract suite with SYSKNIFE_TEST_POSTGRES_URL and SYSKNIFE_REQUIRE_POSTGRES=1 set and -- --include-ignored.
Comment here before you start. The migration ordering is worth agreeing on before any code exists.
The transaction chain signs
caller_principal, captured inhandle_previewatcrates/sysknife-daemon/src/dispatcher.rs:2106, so a verified row answers which account asked for an action.ChainIdentity::V3states why the field exists (crates/sysknife-daemon/src/audit_chain.rs:466):The approval chain never got the same treatment.
EventContent::canonical_bytessigns six fields, and no account appears among them (crates/sysknife-daemon/src/audit_chain.rs:934):TransactionStore::approve_transactiontakes a transaction id and nothing else (crates/sysknife-daemon/src/transactions.rs:548), whilehandle_approveholds aCallerAttributionand passes it toauthorize_for_transactiona few lines earlier (crates/sysknife-daemon/src/dispatcher.rs:1361).claim_approved_for_executionis the same (transactions.rs:652): the account that spends the receipt is never written down.What it costs
Two admins on one host, alice at uid 1000 and bob at uid 1001, both in
sysknife-admin. Alice's agent previewsAddAuthorizedKey, bob typessysknife approve <txid>, alice's agent executes. The verified trail then holds:caller_principal = uid:1000, alice, who previewedapproval_grantedapproval_consumedNothing names bob. The receipt exists so that a human authorises what an agent proposed, and the chain cannot say which human that was.
docs/automatic-rollback.md:130describes the signed fields as "the authorization decision — who approved what, at what risk level". They record who asked.Why this is more than an ALTER TABLE
transactionsversions its signed encoding per row throughchain_version, added in migration 2 so rows written by an older binary keep verifying (crates/sysknife-daemon/src/transactions.rs:171).audit_eventshas no such column in either backend (transactions.rs:179,crates/sysknife-daemon/src/store/postgres.rs:119). Add a field toEventContentwithout versioning the encoding and every event row already on disk reportsBroken, because its signature covers the six-field message.Suggested shape
SQLITE_MIGRATIONSand the PostgresMIGRATIONS:audit_events.chain_versiondefaulting to 1,audit_events.caller_principalnullable, for the reason spelled out in the migration-2 comment.EventIdentityenum besideChainIdentity, whereLegacyV1signs today's six fields andV2appends the principal, withevent_messageselecting per row.CallerPrincipalintoapprove_transaction,claim_approved_for_executionandrevoke_unconsumed_approval, from theCallerAttributionthe handlers already hold.sysknife audit verifycorrect over a mixed-version event chain.Acceptance
caller_principalon an event reportsBroken.crates/sysknife-daemon/tests/postgres_store.rs:298is the pattern for the Postgres side.Difficulty: hard. Two storage backends, a signed encoding, and a compatibility fixture.
Getting started
CONTRIBUTING.md has the build and test commands, docs/architecture.md covers the trust boundary, and docs/the-audit-chain.md explains what the chain signs today. No CLA and no copyright waiver. The project is MIT.
The Postgres half needs a local database.
podman run -d -e POSTGRES_USER=sysknife -e POSTGRES_PASSWORD=sysknife -e POSTGRES_DB=sysknife_test -p 5432:5432 docker.io/library/postgres:17-alpine, then run the contract suite withSYSKNIFE_TEST_POSTGRES_URLandSYSKNIFE_REQUIRE_POSTGRES=1set and-- --include-ignored.Comment here before you start. The migration ordering is worth agreeing on before any code exists.