Docs / Guides
contract disclosure · 60-visibility.md · 61-disclosure.md · 62-accountability.md · guide · card
Visibility, disclosure and accountability
The problems this contract solves
A Contextful workspace indexes an organization’s wiki, drive and chat. Three
problems follow. A reader sees only what the source lets that reader see
(visibility ). A figure computed from other people’s rows reaches one asker without
exposing any contributor (disclosure ). Every read leaves a verifiable trace, and a
person or a tenant is removable on demand (accountability ).
Visibility: mirroring source permissions
The example is a wiki page shared with the group planning, which contains eng-leads,
which contains one reader.
A pack is the reviewed unit that loads one source with its access mapping; a table
with no mapping fails at diagnosis (mapping-absentdisclosure.pack.mapping-absent A pack landing a table with neither an access mapping nor excluded raises VisibilityMappingAbsent at diagnose, naming the pack and the table. ). A sweep
reads the source’s permission state and commits it as one run
(sweep-verbdisclosure.sweep.sweep-verb visibility sweep --source <s> --kind full|incremental|webhook --grants <file> [--directory <file>] runs one sweep of a declared source from its files and commits as one run. ). Grants arrive as ordinary rows of reserved
_visibility tables (grants-tabledisclosure.mirror.grants-table A sweep lands a source's access data as ordinary rows of _visibility/<source>/resources, grants, principals, group_members, identity_links, tombstones and freshness, through the land path and its write lock. ).
For the wiki, the sweep writes one resource row, one grant to planning and two
membership rows. A grant naming no resource fails at commit
(orphan-grantdisclosure.sweep.orphan-grant A grant landing with no resource row, or on a resource of the unknown class, raises VisibilityOrphanGrant at commit and joins no reachable set. ). A Drive sweep follows every permission page of each
file before its grants replace earlier ones (permission-pagesdisclosure.drive-sweep.permission-pages For each governed file, the sweep follows every permissions.list page under the connection credential before its grants replace prior grants. ).
Flowchart diagram
Flowchart, top to bottom. 9 nodes, 8 edges.
connector pack → ACL sweep job [access mapping]
visibility: ACL sweep job → access tables [copied grants]
visibility: access tables → reader [reachable rows, within budget]
reader → audit chain [recorded read]
disclosure: release job → published aggregate [suppressed, noised figures]
disclosure: published aggregate → reader [cohort answer]
accountability: audit chain
accountability: erasure cascade → purge receipt [signed]; → audit chain [erasure entry]
accountability: purge receipt
visibility
copied grants
ACL sweep job
access tables
disclosure
suppressed, noised figures
release job
published aggregate
accountability
signed
erasure entry
audit chain
erasure cascade
purge receipt
access mapping
reachable rows, within budget
recorded read
cohort answer
connector pack
reader
At read time, a bounded group closure walks planning, then eng-leads, then the
reader (closure-walkdisclosure.reach.closure-walk The closure walks the group graph with a visited set, ending any cycle, to a depth of max_group_depth, default 8 hops, and a breadth of 10000 nodes. ). The registered view admits a row only when
its resource holds a grant in that reachable set (semi-joindisclosure.reach.semi-join The semi-join opening relation-order admits a row of a bound table when its resource_key value names a resource with a read-conferring, unexpired, untombstoned grant in the reader's reachable set. ). The
reachable set holds every principal the subject reaches through identity links and
groups (subject-principalsdisclosure.reach.subject-principals A subject user://<p> reaches the user principal spelled <p> and each principal an identity_links row maps to it, then the groups those principals belong to. ).
Fidelity states how closely a table follows its source, and the source family caps
the claim (family-bounddisclosure.declare-fidelity.family-bound A person-container table at a servable level, or a directory table above excluded, raises VisibilityFamilyBound, and the manifest declaring it does not load. ).
When permission state goes stale
An editor removes planning from the page. The table’s staleness budget, a declared
duration (budget-grammardisclosure.bound-staleness.budget-grammar max_acl_staleness is a positive integer with one suffix from s, m, h, d. A compound form, a bare number or another suffix raises VisibilityBudgetMalformed, naming the table and the text read. ), bounds how long the old grant
keeps answering. If the sweep stalls past the budget, the reader’s next
read fails instead of serving a revoked grant (access-staledisclosure.bound-staleness.access-stale A read raises VisibilityAccessStale (HTTP 503, carrying source, lag, budget and last observation) when a touched table's source lag exceeds its budget, the source has no completed full run, or the public-status watermark is past budget. ).
A table declared public_only instead narrows to public rows past its budget
(public-onlydisclosure.bound-staleness.public-only Past its budget, a public_only table whose source has a completed full run and a public-status watermark within budget serves only rows whose resource holds a read-conferring, unexpired public grant. ). The engine retains no reachable set computed in
either state (degraded-uncacheddisclosure.reach.degraded-uncached Retaining a reachable set or result produced past a budget, or under the narrowing posture, raises VisibilityDegradedCached. ).
Explaining a decision
Asked why a colleague lacks access to the page, audit explain decides from current grants
(decisiondisclosure.explain.decision audit explain --table <t> --subject <principal> answers admit when a grant the exchange policy issues for the --role names, else its default_grants, carries read over a pattern covering the table, and deny otherwise. ). The explanation names groups on the path and omits
their members (groups-not-membersdisclosure.explain.groups-not-members A reader-facing explanation rendering the member identities of a group on the path raises VisibilityIndividualNamed. ).
A release computes statistics over contributors’ rows. It first reserves
each contributor’s spend against a lifetime budget, and a failed reservation publishes
nothing (budget-reservationdisclosure.release.budget-reservation Before computing, a release reserves each contributing unit's declared per-run spend against its lifetime cap in one catalog transaction. A failed reservation raises DisclosureUnitBudgetExhausted, and the release publishes nothing. ). A group key outside the permitted
columns fails (group-keydisclosure.release.group-key A group key not a subset of the permitted grouping columns raises DisclosureGroupKeyNotPermitted. ). A model declaring a disclosure policy
publishes only through a release: a plain build of it, or a build job naming it, fails
before staging any row (governed-builddisclosure.release.governed-build A build of a model declaring [model.disclosure] outside release-verb raises DisclosureReleaseRequired and publishes nothing. ).
The release suppresses a group whose distinct contributors fall under the declared
minimum (min-group-sizedisclosure.suppress.min-group-size min_group_size counts distinct contributors and is at least 2 subjects. A group whose noised distinct-contributor count falls under it is suppressed, and a smaller declared value raises DisclosureMinGroupSizeBelowFloor. ). Every suppressed group together leaves one
sentinel row with no key or figure (sentinel-rowdisclosure.suppress.sentinel-row A release with emit_sentinel = true represents every suppressed group together as one __suppressed__ row carrying no group key or exact figure. ).
A model declares whether its figures describe one person or a cohort, and a model
declaring neither is per-person (governance-declarationdisclosure.bound-cohort.governance-declaration [model.disclosure] declares governance, per-person or cohort, naming whether each published figure describes one person or a cohort; an absent governance is per-person, and another value raises spec-invalid. ). A
cohort key resolving to one person fails at declaration
(singleton-cohortdisclosure.bound-cohort.singleton-cohort A cohort model whose permitted grouping columns or non-contributor grain columns name its contributor key or a forbidden column raises DisclosureSingletonCohort at declaration, before any row lands under it. ).
The tables a cohort model’s statement reads hold one row per contributor, so a read of
one fails unless the credential grants read-individual over it
(per-individual-rowdisclosure.bound-cohort.per-individual-row A query, template, retrieve, row, subscribe or recall read touching a per-individual table raises DisclosurePerIndividualUngranted unless a grant holds read-individual over that table; a derive task's input read is a row read. ). A statement grouping the published cohort
table by fewer than all its group-key columns fails: the coarser groups sum figures no
release noised (cohort-wideningdisclosure.bound-cohort.cohort-widening A template or statement read grouping a cohort model's published table by a strict subset of the group key its release records raises DisclosureCohortWidening; grouping by the whole key or a superset of it reads. ).
A derive or synthesis pass over such a table needs read-individual too; its output
inherits the rule (derive-individualdisclosure.bound-cohort.derive-individual A derive run whose source or joined table is per-individual refuses as per-individual-row at run start, before selecting any unit, unless its credential holds read-individual over that table; a run without a credential refuses too. ,
synthesize-individualdisclosure.bound-cohort.synthesize-individual A synthesis pass whose source or target is per-individual refuses as per-individual-row at run start, before reading a row or calling its endpoint, unless its credential holds read-individual over that table. ). A scheduled synthesis holds it only
when its job declares read_individual = true (fire-individualsurface.dispatch.fire-individual A synthesize job declaring read_individual = true also reaches read-individual on its source and its target; no other fire reaches read-individual. ).
A named query template holds one statement
(single-statementdisclosure.template.single-statement A declaration holding more than one statement raises DisclosureTemplateMultiStatement. ). The offline diagnostic checks every
published model’s policy from local files (check-verbdisclosure.set-mode.check-verb contextful disclosure check --config <path> diagnoses each published model's disclosure policy and statement from local files and reports every refusal by model name. ).
Accountability: the audit chain
Each read appends one audit entry, and rows leave only after that entry syncs
(read-entrydisclosure.record.read-entry Each read tool call the face answers with a result, over stdio or HTTP, appends one entry, and the result leaves only after that entry's append group syncs; initialize, ping and tools/list append none. ). Entries hash-link into segments, each closed by a
signed root (segmentdisclosure.record.segment Entries append to a numbered segment file in ascending seq, and a segment closes under one signed root once it holds its chain header's segment size, 4096 entries by default. ).
A proof for one entry verifies offline under the signer’s public key alone
(inclusion-proofdisclosure.attest.inclusion-proof prove returns one entry, its chain header, its RFC 6962 audit path and its segment's signed root; the proof verifies offline under the signer's public key alone. ). Verification of a damaged chain fails at the
earliest broken index (broken-chaindisclosure.attest.broken-chain A disagreeing digest, entry format or Merkle root, a sequence gap, a failing signature, or, beside chain.tip, chain.held or a signed root, an absent chain or a missing or unsigned tip raises AuditChainBroken at the earliest failing index. ).
Erasure
When the reader leaves, an operator erases their rows under the forget grant
(privilegedisclosure.erase.privilege Erasure sits outside the default grant set, and a caller without the forget grant raises ErasureUngranted. ). The cascade invalidates every fact derived from the
erased rows in the same operation (cascadedisclosure.erase.cascade In the same operation the cascade invalidates every derived fact whose provenance reaches the subject key or a tombstoned identifier within 16 hops. ), and the files holding
them are rewritten or collected (physical-removaldisclosure.erase.physical-removal Within 24 h of the erasure's commit, every file holding an erased row, superseded snapshots inside retention included, is rewritten or collected. ).
A dead letter
repeats the content of the rows its failed batch carried, so each letter citing an erased
row leaves with it (dead-lettersdisclosure.erase.dead-letters Erasure also rewrites each row of a declared table's <table>_dead_letter whose dead-letter-evidence cites a removed row; undecodable evidence follows unsupported-scope. ). A letter without
evidence cites no row, so no subject reaches it;
context erase --dead-letters-without-evidence clears those letters
through the same signed publication
(dead-letters-without-evidencedisclosure.erase.dead-letters-without-evidence context erase --dead-letters-without-evidence, alone or beside a selector, removes each <table>_dead_letter row without evidence for every requested table, under dead-letter-grant and atomic-publication. ).
A tenant purge returns a signed receipt naming a salted pseudonym, not the tenant
identifier (pseudonymdisclosure.receipt.pseudonym tenant_hash is SHA-256 over a random per-receipt salt and the tenant identifier. A receipt carrying the identifier in cleartext raises ReceiptIdentifierLeak before signing. ).