Access Control
How Hakiri decides what an agent (or human, or service) can see when reading the context store. This spec covers:
- Capability tokens — the principal model with agents as first-class subjects.
- Row-level security (RLS) — filter rows per token.
- Column-level security (CLS) — mask, redact, hash, or tokenize columns per token.
- Data clean rooms — query patterns where the requesting agent never sees raw rows: aggregate-only queries, hashed joins, query whitelisting, and differential-privacy budgets.
Related:
- Product framing and pillars: PRD Pillar 3 (agentic ACLs) and Pillar 6 (compliance).
- Hard problem framing: PRD § Challenge 1.
- Sync conflict resolution underlying lease semantics: ADR-0005.
- Agents are first-class principals, not bolted-on service accounts. A short-lived agent gets a short-lived token; provisioning rates that work for humans do not have to work for agents.
- Authorize at three layers — write time, sync edge, query time — so a stolen credential at one layer does not bypass the others.
- Policies are data, not console settings. RLS predicates and CLS rules live in the manifest, are PR-reviewable, and travel with the project.
- Auditable by construction — every read emits an OTel span with the full subject tuple, token id, and rows touched. The trail is queryable at machine speed.
- Offline-verifiable. Tokens verify against a public key in the manifest; no always-on policy server.
- Clean-room joins are a built-in pattern, not a custom integration. Two parties sharing a bucket can run joins where neither sees the other’s raw rows.
Subject model — who is the principal
Section titled “Subject model — who is the principal”A subject is a tuple, not a flat string. The tuple is what tokens are issued for and what every audit span records:
subject = { agent: "agent://claude-research" // the LLM client (model + harness) — SELF-ASSERTED in v0 host: "host://laptop-jarvis-01" // where the agent is running — SELF-ASSERTED in v0 on_behalf_of: "user://jarvis@fractalbox" // the human (or service) ultimately requesting task: "task://01HXYZ..." // the conversation / task scope, if any inference_zone: "local:device" // where the LLM consuming the response is running — SELF-ASSERTED in v0 incognito: true // convenience flag pinning zone to local/on-prem — see 15-inference-placement.md}inference_zone and incognito govern where the bytes are allowed to be processed once they’re read — distinct from “who can read what.” Full mechanics, the zone vocabulary, and the Incognito-mode UX live in 15-inference-placement.md; the rest of this spec composes naturally with the zone filter (it runs as one more pass alongside RLS / CLS).
All four fields are recorded; tokens are scoped to a subset of the tuple. A token issued to agent://claude-research × on_behalf_of://jarvis × task://01HXYZ cannot be reused by agent://claude-research × on_behalf_of://alice × task://other. This is the contextual-caveat model from macaroons / biscuits applied to the agent shape.
For non-agent principals (humans, service accounts, CI jobs), the same tuple works with the irrelevant fields set to * or omitted:
subject = { user: "user://jarvis@fractalbox" host: "host://office-laptop"}Attestation — what is verified vs. what is asserted
Section titled “Attestation — what is verified vs. what is asserted”Honesty about what the v0 model proves:
| Field | v0 attestation | Audit trail labeling |
|---|---|---|
agent | Self-asserted by the MCP client. A compromised laptop replacing Cursor with an attacker-controlled MCP client claims whatever it likes. | OTel span attribute hakiri.subject.agent.asserted = true |
host | Self-asserted in v0 (hash of binary + per-install machine secret from OS keychain). SPIFFE-flavored workload-identity attestation is M3+. | hakiri.subject.host.asserted = true |
on_behalf_of | Verified at token issuance time against whatever identity provider issued the token (OS keychain authn for local, SSO for the M3 control plane). | hakiri.subject.on_behalf_of.verified = true |
task | Self-asserted by the agent; verified only against the token’s cnf.jkt (see below). | hakiri.subject.task.asserted = true |
inference_zone | Self-asserted by the agent client in v0. Same upgrade path as host — SPIFFE-flavored attestation in M3+. See 15-inference-placement.md. | hakiri.subject.inference_zone.asserted = true |
Proof-of-possession binding
Section titled “Proof-of-possession binding”Every token carries a cnf.jkt claim (a thumbprint of a public key the client must hold). On every request the client signs a request-binding (method + URL + request body hash + nonce) with the corresponding private key. The runtime verifies the signature before honoring the grant.
This is the DPoP-shaped construction. It does not solve the “what is the agent really” problem — a compromised client still holds the private key — but it does solve token theft: an attacker who steals the biscuit at rest cannot use it without also stealing the private key. The two together is a much harder ask than either alone.
Auditors should treat cnf.jkt-bound tokens as “key X presented this read, scoped to subject Y as the holder of X claims to be subject Y.” That is the honest sentence; the spec does not pretend otherwise.
The three enforcement layers
Section titled “The three enforcement layers”Access control is enforced at three distinct layers. A stolen credential at one layer does not bypass the others. The composition of these layers — that no forbidden row reaches the response regardless of filter order, and that response metadata cannot be used as an oracle for forbidden content — is stated and machine-checked as theorems in 16-formal-verification.md.
flowchart LR Source[Source API] --> Writer[hakiri runtime<br/>write-time redaction] Writer --> Bucket[(R2 / S3 / MinIO<br/>Parquet at rest)] Bucket --> SyncEdge[hakiri sync serve<br/>sync-edge filtering] SyncEdge --> Replica[(Replica<br/>laptop / Worker / VPC)] Replica --> QueryEngine[DuckDB / MCP query proxy<br/>query-time RLS + CLS] QueryEngine --> Agent[Agent]
Layer 1 — Write-time redaction (strongest)
Section titled “Layer 1 — Write-time redaction (strongest)”The writer applies redaction before Parquet is written. Sensitive columns are dropped, hashed, or tokenized at ingest. An attacker who steals the raw R2 credential sees only the redacted Parquet.
# in hakiri.toml[[pipeline.tables]]name = "github_issues"
[pipeline.tables.policy] # Write-time: every replica of the bucket has these applied irreversibly redact = ["author.email"] # drop hash = ["author.id"] # one-way hash with project pepper tokenize = ["body"] # FPE; reversible only with KMS keyUse write-time when:
- The field is sensitive enough that no Hakiri replica should ever hold it in cleartext.
- Operator can tolerate not querying the field downstream (or can tolerate querying via the tokenized form).
- Compliance regime requires “data minimization at ingest” (GDPR Art. 5(1)(c), HIPAA minimum-necessary rule).
Layer 2 — Sync-edge filtering
Section titled “Layer 2 — Sync-edge filtering”For replicas that should see some tokens’ data but not others, the sync edge (hakiri sync serve) filters the materialized replica based on the puller’s capability token. The bucket holds the full Parquet (subject to layer-1 redaction); the replica receives only the slices its token allows.
Use sync-edge when:
- Different agents/replicas need different slices of the same table.
- The field must remain queryable in some replicas (so write-time redaction is too strong).
- You can run
hakiri sync serve(a small Worker / Lambda / VM in front of the bucket).
Layer 3 — Query-time RLS + CLS
Section titled “Layer 3 — Query-time RLS + CLS”The DuckDB-backed query proxy (and the in-process CLI) rewrite the agent’s SQL to add row-filter WHERE clauses and column-mask projections per token. Even an agent that gets a replica via layer 2 has its queries further constrained by layer 3 if the token specifies it.
Use query-time when:
- The same replica serves multiple agents with different scopes.
- The filter is dynamic (e.g.,
WHERE customer_id = current_subject.on_behalf_of.account_id). - The masking choice depends on the requester (some agents see raw email, others see
***@example.com).
Capability tokens
Section titled “Capability tokens”Token shape
Section titled “Token shape”{ // Standard claims "v": 1, "iss": "project://fractalbox/hakiri-context", "sub": { "agent": "agent://claude-research", "host": "host://laptop-jarvis-01", "on_behalf_of": "user://jarvis@fractalbox", "task": "task://01HXYZ..." }, "iat": "2026-05-12T10:00:00Z", "exp": "2026-05-12T18:00:00Z", "jti": "tk_01HXYZ...",
// Authority "grants": [ { "actions": ["read"], "tables": ["github_issues", "linear_issues", "notion_research/*"], "rls": { // Row-level: predicate evaluated against each row at query time. // References to ${sub.*} resolve to the calling subject's fields. "predicate": "repo IN (SELECT repo FROM access.repo_grants WHERE user = ${sub.on_behalf_of})" }, "cls": { // Column-level: per-column masking applied at query time. "mask": { "author.email": "redact", // drop / return NULL "body": "truncate(200)", // first 200 chars "ssn": "hash" // sha256 with project pepper } }, "constraints": { "max_rows_per_query": 10000, "rate_limit_per_min": 60, "regions_allowed": ["eu-central", "eu-west"] } } ],
// Caveats — additional restrictions that compose with the grant "caveats": [ "expires_at <= 2026-05-12T18:00:00Z", "subject.task == \"task://01HXYZ\"" ]}Format choice (open question — see PRD.md § Open product questions)
Section titled “Format choice (open question — see PRD.md § Open product questions)”Three candidates evaluated:
| Format | Pros | Cons | Verdict |
|---|---|---|---|
| Signed JWT (Ed25519) | Ubiquitous tooling; trivial verification. | No native attenuation; revocation is hard; size grows with claim count. | Fallback for v0 if biscuit Rust support disappoints. |
| Macaroons | Contextual caveats are the original design; battle-tested. | Rust ecosystem lags; per-request HMAC verification only (no public-key). | Off-table — public-key verification is required for offline use. |
| Biscuits (leaning) | Native attenuation; Datalog policy language; offline public-key verify; mature Rust crate. | Newer; smaller ecosystem; Datalog adds learning curve. | Likely choice. Spike planned in M2. |
Biscuit gives us attenuation for free: an agent that holds a read:* token can derive a more-restrictive child token (read:github_issues only, expires in 5 min) to hand to a sub-agent without contacting the issuer. That property maps directly onto the agent-on-behalf-of agent pattern.
Issuance and revocation
Section titled “Issuance and revocation”- Issuance.
hakiri token issue --subject agent://X --table Y --action read --ttl 24hwrites a signed token. The project’s signing key lives in the operator’s keychain or KMS — never in the manifest. - Distribution. Tokens travel via MCP at agent startup (the client’s responsibility) or via
HAKIRI_TOKENenv var for non-MCP callers. - Revocation.
- Short TTLs (≤24h default) are the primary revocation mechanism — no revocation list to keep in sync.
- For immediate kill, a scoped revocation epoch lives in the catalog as
(project, tenant, principal_class) → epoch. Bumping(project=X, tenant=acme, principal_class=agent://research)invalidates only that slice — not the whole project. Project-wide epoch bump remains the bluntest option for “we think the signing key was leaked,” but it is no longer the only option. Bumping a principal-class epoch is the right primitive for “one agent went rogue.” - Per-token revocation by
jtiis a v2 feature; v0 relies on TTLs + scoped epoch.
- Token issuance auth.
hakiri token issuerequires the project’s signing key, held in the operator’s keychain or KMS. Ifhakiri serveexposes a token-issuance HTTP endpoint (M2+), that endpoint requires an admin capability token + optional mTLS — there is no separate auth model for issuance, just the same capability primitive applied to itself.
Row-level security (RLS)
Section titled “Row-level security (RLS)”Declaring RLS in the manifest
Section titled “Declaring RLS in the manifest”RLS predicates live as a policy block on a table:
[[pipeline.tables]]name = "customer_tickets"
[[pipeline.tables.policy.rls]] name = "per_account_isolation" applies_to = "any" # all subjects unless overridden predicate = "account_id = ${sub.account_id}" # claim from subject
[[pipeline.tables.policy.rls]] name = "audit_role_full_read" applies_to = "subject.role == 'compliance-audit'" predicate = "true" # override per-account isolationPredicates use a typed boolean expression language (a SQL boolean subset — column refs, comparison ops, AND/OR/NOT, IN, LIKE, declared-safe scalar functions). They are parsed to an AST at manifest load and rejected if they reference anything outside that grammar. ${sub.*} slots are bound as parameters, never interpolated as strings.
How RLS executes — parameterized, never string-concatenated
Section titled “How RLS executes — parameterized, never string-concatenated”Subject claims are bound as parameters, not interpolated into a SQL string. The runtime maintains a per-session subject table that DuckDB joins against, and the predicate is rewritten to reference that table:
-- agent's query (subject claims supplied via the session, not the SQL)SELECT * FROM customer_tickets WHERE created_at > '2026-05-01';
-- runtime rewrite — subject claims live in a session-scoped CTE bound at query startWITH __subject(account_id, role) AS (SELECT ?, ?), -- parameters, not interpolation __rls_customer_tickets AS ( SELECT t.* FROM customer_tickets t, __subject s WHERE t.account_id = s.account_id -- predicate references the join, never raw subject strings )SELECT * FROM __rls_customer_tickets WHERE created_at > '2026-05-01';Why this matters: a token whose on_behalf_of is user://alice@acme'; DROP TABLE customer_tickets;-- is harmless. The value lives in a DuckDB prepared-statement parameter; it cannot escape the parameter slot and become SQL. String interpolation of subject claims into raw SQL is forbidden anywhere in the runtime, full stop — there is no format!("WHERE x = {}", subject.user) code path. Verified by a CI grep gate.
DuckDB’s query planner pushes the join filter down so RLS-filtered scans are not a performance penalty in the common case.
Predicate composition
Section titled “Predicate composition”Multiple RLS predicates on the same table compose with AND (most restrictive wins) unless one explicitly declares override = true for a matching applies_to. The composition order is:
- Token’s own
rls.predicate(fromgrants[].rls). - Table-level RLS predicates (from
policy.rlsblocks) that match the subject. - Project-level default-deny (no rows visible) if no grant matches.
Column-level security (CLS)
Section titled “Column-level security (CLS)”Mask strategies
Section titled “Mask strategies”| Strategy | What it does | Reversible | Use case |
|---|---|---|---|
redact | Replace with NULL | No | PII the agent should not see at all |
empty | Replace with empty string for strings, NULL otherwise | No | Same, but agents that expect a string column |
hash | HMAC-BLAKE3 with project pepper | No (for joining: see hashed-join clean-room pattern below) | Join keys that must not leak identity — constrained for low-cardinality / known-format columns, see warning below |
tokenize(format) | Format-preserving encryption (FF1) with per-column KMS key | Yes with KMS key | Reversible analytics under audit |
truncate(N) | First N chars (string), top-K bits (numeric) | No (information loss) | Free-text fields where partial visibility is enough |
bucket(spec) | Generalize: bucket("age:5y") → 25 maps to “25-29”; bucket("zip:3") → “94110” → “941**“ | No | k-anonymity preprocessing |
range(spec) | Float to range: range("salary:10k") → 87500 → “[80k,90k)“ | No | Salary, geolocation precision reduction |
Hash strategy — low-cardinality guardrails
Section titled “Hash strategy — low-cardinality guardrails”hash alone does NOT protect low-cardinality or known-format columns. SSN search space is 10⁹ (trivially exhaustible), phone is 10¹⁰ (minutes), email-against-public-dictionary recovers most common addresses in seconds. A static project pepper does not change this — the attacker who steals the hashed column also steals the pepper at the same time (it’s in the same project).
For columns declared with pii_type ∈ {ssn, phone, email, mrn} in the table schema, the manifest validator refuses strategy = "hash" as the sole protection. Acceptable combinations:
hash+bucket— hash and then generalize (lose precision before joining).hash+truncate— hash and keep only N bits.tokenize— reversible only with the KMS key.redact— full drop.
For high-entropy columns (random UUIDs, opaque tokens) hash alone is fine and the validator allows it.
For multi-tenant clean-room joins, hash uses per-pair re-randomization (the pepper is exchanged via the escrow channel and rotated per join) rather than a long-lived project pepper. DH-based PSI is the v2 upgrade; v0 ships HMAC-BLAKE3 + per-pair pepper as the floor.
Declaring CLS in the manifest
Section titled “Declaring CLS in the manifest”[[pipeline.tables]]name = "employees"
[pipeline.tables.policy.cls] # Applied at query time per token. Layer-1 write-time redaction # is configured separately in `policy.redact / hash / tokenize`. email = { strategy = "redact", except = ["subject.role == 'hr'"] } ssn = { strategy = "hash", combine = "bucket" } # validator requires combine for ssn pii_type salary = { strategy = "range:10k", except = ["subject.role IN ('hr', 'finance')"] } birthdate = { strategy = "bucket:1y" }The except clause is a boolean expression evaluated against the subject — same parameterization rules as RLS predicates (parsed AST, no string interpolation).
Interaction with SQL features
Section titled “Interaction with SQL features”SELECT *expands to the masked projection; the agent sees the masked value, not an error.WHERE email = '...'against a redacted column returns no rows (the value the agent provided is compared againstNULL); use thehashstrategy if equality filtering is required.- Aggregations (
COUNT,AVG) work on the masked column;range/bucketstrategies are designed to preserve aggregate utility while reducing precision.
Data clean rooms — agents querying without seeing raw data
Section titled “Data clean rooms — agents querying without seeing raw data”A clean room is a query pattern where the requesting agent (or party) gets a derived result but never sees the raw rows. Hakiri supports four clean-room patterns, composable with the layers above.
Pattern A — Aggregate-only with k-anonymity threshold
Section titled “Pattern A — Aggregate-only with k-anonymity threshold”The token’s grant restricts the agent to aggregate queries, and the runtime drops any aggregate row whose underlying group size is below a threshold k.
{ "actions": ["aggregate"], "tables": ["customer_events"], "constraints": { "min_group_size": 50, // k-anonymity threshold "allowed_aggregates": ["COUNT", "SUM", "AVG", "MIN", "MAX", "approx_count_distinct"], "max_groups_per_query": 1000 }}Agent SQL:
SELECT region, COUNT(*) AS users, AVG(spend) AS avg_spendFROM customer_eventsGROUP BY region;Runtime rewrite:
WITH __agg AS ( SELECT region, COUNT(*) AS users, AVG(spend) AS avg_spend FROM customer_events GROUP BY region)SELECT * FROM __agg WHERE users >= 50; -- drop small groupsGroups smaller than k are suppressed silently (returned as a single “below threshold” sentinel row with count rolled up) so the agent cannot infer the existence of small groups by counting suppressions.
Pattern B — Hashed join (private set intersection-flavored)
Section titled “Pattern B — Hashed join (private set intersection-flavored)”Two parties hold disjoint datasets and want to join on a key (email, phone, hashed ID) without either party seeing the other’s rows. Both write the join column under a shared deterministic hash (project peppers must match).
[[pipeline.tables.policy.clean_room]]mode = "hashed_join"join_key = "email"hash_spec = "sha256:project-pepper-v1"Each party’s writer applies the hash to email at write time (layer 1). When a query joins party A’s customer_events to party B’s marketing_attribution on email_hash, the join matches rows present in both sides without revealing emails missing from one side. The agent sees only the matched rows; non-matching rows of either dataset remain private.
Limitation: vulnerable to dictionary attacks against the hashed values if the email domain is small. Mitigation: combine with min_group_size (Pattern A) or use a salted PRF rather than plain SHA when v2 ships.
Pattern C — Whitelisted queries
Section titled “Pattern C — Whitelisted queries”The token allows only pre-approved query templates, not free-form SQL. The agent submits a query id and parameters; the runtime executes the bound template.
[[pipeline.queries]]id = "ticket_count_by_account"sql = "SELECT COUNT(*) FROM tickets WHERE account_id = $1 AND created_at > $2"params = ["account_id:int", "since:timestamp"]allowed_subjects = ["agent://*", "user://*"]{ "actions": ["execute"], "queries": ["ticket_count_by_account"], "constraints": { "max_rows_per_query": 1 }}The agent calls context.execute_query(id="ticket_count_by_account", params={account_id: 42, since: "2026-05-01"}) via MCP. The runtime never accepts SQL from the agent under this grant. This is the strongest clean-room pattern — useful when the agent is fully untrusted but its tasks are bounded.
Pattern D — Differential-privacy budget (v2+ candidate)
Section titled “Pattern D — Differential-privacy budget (v2+ candidate)”Per-token noise budget on numeric aggregates, with the runtime tracking the cumulative privacy spend.
{ "actions": ["aggregate"], "tables": ["customer_events"], "dp": { "epsilon_total": 1.0, // privacy budget across the token's lifetime "mechanism": "laplace", "queries": ["sum", "count", "mean"] }}Each aggregate query consumes some ε and returns the result with calibrated Laplace noise; the runtime refuses queries once the budget is exhausted. Pattern D needs library support (e.g., opendp-rs) and is not in v0; tracking as v2 candidate.
Combined enforcement — a worked example
Section titled “Combined enforcement — a worked example”A CS agent running on host://office-laptop, on behalf of user://carol@acme, scoped to task://renewal-review-Q3, queries the joined customer-360 store:
- Token check. The MCP server verifies the biscuit token; subject tuple matches; not expired; signing key trusted; not before the revocation epoch.
- Grant lookup. Token grants
readoncustomer_360.*withrls.predicate = "account.csm = ${sub.on_behalf_of}"andcls.mask = {email: redact, mrr: range:10k}. - RLS rewrite. The agent’s
SELECT * FROM customer_360.accounts WHERE risk_score > 0.7becomesSELECT … FROM (SELECT * FROM customer_360.accounts WHERE csm = 'carol@acme') WHERE risk_score > 0.7. - CLS rewrite.
emailprojects asNULL;mrrprojects as a bucketed range; other columns pass through. - Layer-1 already applied. SSN is not in the Parquet at all (write-time redact).
- Audit span. An OTel span records subject tuple, token id, query hash, table touched, rows returned (post-RLS), and total bytes. The span trail is queryable by the auditor.
The agent sees a filtered, masked result. Carol’s manager, with a different token (role == 'sales-lead'), would see all accounts on her team — same query, different token, different result.
Multi-tenant clean rooms — two orgs, one bucket
Section titled “Multi-tenant clean rooms — two orgs, one bucket”A clean-room deployment puts two (or more) parties’ Hakiri instances against a shared S3-compatible bucket they both control via a third party (escrow). Each party:
- Writes their own tables under their tenant prefix, with their own redaction policies (layer 1).
- Grants the other party’s agents capability tokens limited to clean-room patterns (B + C above).
- Cannot read the other party’s tables outside the clean-room grants — the sync edge (layer 2) enforces this.
The escrow / shared bucket owner does not see plaintext either: optional layer-1 encryption (Parquet modular encryption) with keys held in each party’s KMS ensures the bucket operator sees opaque ciphertext.
Cross-tenant isolation requirements
Section titled “Cross-tenant isolation requirements”Shared-bucket clean rooms have a real attack surface that “trust the escrow” does not adequately address. Three load-bearing protections, all required for the M3 clean-room mode:
- Per-tenant signed manifest entries. The top-level
manifest.jsonis not unauthenticated. It is a Merkle tree of per-tenant subtrees; each subtree is signed with the tenant’s project signing key. A reader verifies the signature on its own subtree’s path to the root before honoring any entry. A malicious co-tenant cannot rewrite the top-level manifest to drop the other party’s snapshots without invalidating signatures. - IAM-enforced per-tenant write prefixes. The escrow operator configures the bucket so each tenant has write access only to its own prefix (
tenants/<tenant-id>/) and read access only to the cross-tenant clean-room artifacts. S3 prefix policies and R2 token scoping support this directly; for MinIO / Garage / SeaweedFS the escrow must implement it explicitly. Without IAM enforcement, signature verification protects against undetected tampering but not against denial-of-service via overwrite. - Per-tenant project signing keys. Each tenant holds its own signing key in its own KMS; the escrow operator never sees any tenant’s signing material. Cross-tenant token grants are signed by the issuing tenant’s key and verified by the receiving tenant before honoring.
These are deployment requirements, not trust assumptions. The M3 clean-room mode refuses to operate against a bucket that doesn’t meet them; hakiri sync diagnose --clean-room validates the IAM configuration before any cross-tenant write.
Typical deployment shapes:
- Brand × ad-network attribution. Each side has user-event data; the clean-room join (hashed
user_id) produces only the attributable subset, withmin_group_size = 100suppressing small slices. - Bank × fraud consortium. Each bank contributes hashed transactions; queries are whitelisted (Pattern C) to a small set of pre-vetted aggregates.
- Healthcare network × pharma study. De-identified cohort data joins on hashed patient_id; the pharma side runs aggregate queries with k-anonymity.
This is an M3 feature. The capability-token + sync-edge + RLS/CLS layers in M2 are the prerequisites.
Audit trail
Section titled “Audit trail”Every read emits an OTel span with these attributes (in addition to the standard hakiri.run_id, hakiri.pipeline_id):
| Attribute | Example |
|---|---|
hakiri.subject.agent | agent://claude-research |
hakiri.subject.host | host://office-laptop |
hakiri.subject.on_behalf_of | user://carol@acme |
hakiri.subject.task | task://renewal-review-Q3 |
hakiri.token.jti | tk_01HXYZ... |
hakiri.token.issued_at | 2026-05-12T10:00:00Z |
hakiri.query.hash | sha256:abc123… (post-rewrite SQL hash) |
hakiri.query.tables | ["customer_360.accounts"] |
hakiri.policy.rls_applied | ["per_account_isolation"] |
hakiri.policy.cls_applied | ["email→redact", "mrr→range:10k"] |
hakiri.result.rows | 42 |
hakiri.result.bytes | 5310 |
hakiri.result.suppressed_groups | 3 (for aggregate clean-room queries) |
Tamper-evident audit log (alongside OTel)
Section titled “Tamper-evident audit log (alongside OTel)”OTel alone is not enough for audit integrity — a compromised or misconfigured collector can drop or alter spans with no detection. Hakiri writes a parallel local append-only audit log under .hakiri/audit/:
- Each entry is a JSON record with the same attributes as the OTel span, plus a sequence number and a cryptographic hash linking it to the previous entry (Merkle-style hash chain over span batches).
- Every N minutes (configurable, default 10), the runtime computes a signed root over the chain segment and writes it to the sync bucket under
audit/<project>/<segment-id>.jsonalong with the chain segment itself. - The signed root uses the project signing key (same key that signs tokens). An auditor can verify any historical span by walking the chain from the signed root to the claimed entry.
- Optional: commit signed roots to an external transparency log (Sigstore Rekor or equivalent) so the operator cannot later remove or rewrite a segment without detection.
The OTel span trail remains the queryable projection — fast to ad-hoc-query, integrated with whatever observability stack the operator already runs. The hash-chained local log is the attestable record — authoritative for “did the operator alter this.” Auditors get both: speed from OTel, integrity from the chain.
If the OTel sink is unreachable, the local audit log still grows; no agent read happens that isn’t recorded somewhere durable. If the local audit log can’t be written (disk full, permission error), the read is refused — there is no “fail-open” path that returns rows without an audit entry.
These spans + the chain are the audit log. They are queryable for “which agent saw which row when, and under what policy” in under a second over a 24-hour window. The trail is what makes compliance attestation tractable.
What this spec deliberately leaves out
Section titled “What this spec deliberately leaves out”- OAuth / SSO integration for the human side. v0 assumes humans get tokens the same way agents do — issued by the operator, distributed however. SSO integration is M3+ and orthogonal.
- Encryption key management. Covered in 04-context-store §Encryption. The access-control layer assumes keys are available; it does not manage them.
- Network-level controls (VPC, egress firewalls). Operator’s responsibility; Hakiri’s job is the application-level model.
- Mutual TLS / workload identity. SPIFFE-shaped workload identity for the
hostcomponent is on the wishlist (Challenge 1 references SPIFFE). Not in v0.
Open questions
Section titled “Open questions”- Predicate language. A typed boolean subset with parameterized subject-claim binding is the v0 minimum. Datalog (via biscuit’s authority language) is the v2+ upgrade path — better composition and reasoning, at the cost of teaching the language. Decided: stay on the typed-boolean subset until usage demands more.
- CLS
exceptpredicates. Inlineexceptlists work for simple cases. For complex policies (HR can see email except when subject is contractor and the employee is in EU), factor out into a separate policy DSL — but only if usage demands. - Aggregate cardinality side channels. Pattern A suppresses small groups but a series of carefully chosen queries can still leak whether a specific record exists. Differential privacy (Pattern D) is the answer; without it, document the limitation and recommend bigger
min_group_size. - Hashed-join — strength under repeated joins. v0 ships HMAC-BLAKE3 with per-pair re-randomization (rotating pepper exchanged via escrow). DH-based PSI is the v2 upgrade; TEE-shaped joins are an alternative for very high-risk joins. The “minimum acceptable” line is the per-pair re-randomization — static-pepper SHA-256 is explicitly not in v0.
- DuckDB engine integration. Decided: parameterized planner-binding (per-session subject CTE), not string rewrite. The runtime parses RLS predicates to an AST at manifest load and rejects anything outside the typed-boolean subset. No raw-SQL interpolation path exists.
- Token format finalization. Biscuit vs JWT — see PRD § Open product questions.
- Sidecar encryption scope. When Parquet is encrypted at rest, HNSW / Tantivy / Bloom sidecars must also be encrypted (or, for redacted columns, not exist on disk). Implementation detail tracked in
04-context-store.md§ Encryption.