Protocol
The normative evidence, solving, contract, and proof protocol.
This document defines the deterministic local contract and guarded live-proof protocol implemented by GrantTrace.
Versions
- Observation schema:
1 - Contract schema:
3 - Proof-report schema:
3 - Tool version:
0.1.0-beta.1 - Pinned GitHub REST API:
2026-03-10 - Offline catalog source:
github-docs - Offline catalog version:
2026-03-10.20260723.1 - Catalog identity: SHA-256 over sorted canonical entries, permission DNF, and official documentation URL
Contract, observation, and report objects are strict. Unknown schema versions and unrecognized fields fail validation.
Guarantee
The protocol supports one claim:
For the GitHub REST operations exercised by these named, recorded scenarios, these are the permissions the scenarios demonstrably require.
The claim is bounded by dynamic scenario coverage and the supported recorder
paths. The injected Node preload observes global-fetch traffic targeting
exactly https://api.github.com, including standard Octokit requests. The
explicit Octokit adapter covers compatible custom transports. The claim does
not extend to unexecuted code, unsupported runtimes or endpoints, or traffic
that bypasses both paths.
Accepted-permissions grammar
GitHub defines commas as conjunction and semicolons as alternatives:
header = conjunction (";" conjunction)*
conjunction = term ("," term)*
term = permission "=" level
permission = lowercase snake case identifier
level = "read" | "write"GrantTrace trims separator-adjacent whitespace, sorts and deduplicates exact terms and alternatives, and rejects:
- empty headers, alternatives, or terms;
- missing or repeated
=; - malformed permission names;
- levels other than
readorwrite; and - the same permission at conflicting levels within one conjunction.
Input header text is never copied into an error.
For example:
pull_requests=read,contents=read; issues=read,contents=readcanonicalizes to:
[
[
{ "permission": "contents", "level": "read" },
{ "permission": "issues", "level": "read" }
],
[
{ "permission": "contents", "level": "read" },
{ "permission": "pull_requests", "level": "read" }
]
]Missing evidence is not an empty requirement.
Permission lattice
For one permission:
absent < read < writeDifferent permission names are incomparable. A route requirement is disjunctive normal form: an OR of one or more AND conjunctions.
GitHub currently models admin for at least one permission. GrantTrace does
not. Any unsupported level fails closed.
Observation
Every NDJSON line is a strict safe object:
type Observation = {
schemaVersion: 1;
scenario: string;
method: "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT" | "UNKNOWN";
routeTemplate: string | null;
status: number | null;
requirements: PermissionDNF | null;
evidenceSource: "runtime_header" | "pinned_catalog" | "none";
finding:
| null
| "unresolved_route"
| "missing_evidence"
| "malformed_header"
| "evidence_contradiction"
| "unsupported_api";
};The recorder accepts route identity only when it can resolve method and path to
exactly one canonical template in the pinned catalog. The injected fetch path
matches only the exact https://api.github.com origin and a supported path
against that catalog without persisting the concrete URL. Off-origin responses
are ignored even when they include X-Accepted-GitHub-Permissions; they cannot
contribute automatic runtime evidence. The explicit Octokit adapter can supply
its pre-expansion relative canonical template.
GraphQL, unsupported API versions, ambiguous or unmatched GitHub paths, and
unsafe candidates never undergo generic redaction: the candidate is discarded
and a safe finding blocks.
No raw header, URL, query, body, response, error, authentication value, owner, repository, or resource identifier is stored.
Recorder session
record [--no-review] NAME -- COMMAND ARGS (with legacy --scenario NAME
compatibility):
- creates ignored, nonsymlink local state with private modes and ownership checks where the platform exposes them when state is absent;
- validates a lowercase safe scenario name;
- creates a
0700temporary session; - injects the Node preload and launches argv with
shell: falseand the user's ordinary test environment plus recorder variables; - streams child stdout/stderr without retaining it;
- enforces a default 15-minute timeout, bounded to one hour;
- remembers a terminal interrupt independently of the child's eventual exit;
- requires a recorder marker and at least one safe observation;
- requires every observation to carry the requested scenario;
- validates all observations in memory;
- removes the session successfully;
- only then atomically replaces the
0600per-scenario NDJSON file; - unless review was explicitly deferred, builds and displays the aggregate contract diff; and
- only in an interactive terminal, asks for explicit acceptance with a default of no.
A failed, interrupted, timed-out, unobserved, or empty child never replaces the
prior successful recording. An unresponsive child receives a bounded
force-kill escalation. A cleanup failure also prevents persistence.
Noninteractive recording never accepts a contract; a semantic diff exits 6.
--no-review omits steps 13–14 so multi-scenario automation can finish every
recording before one required aggregate check.
Evidence resolution
For each canonical route:
- malformed runtime evidence blocks;
- valid runtime and catalog DNF are canonicalized independently;
- equal sources retain both provenance labels;
- unequal sources become
evidence_contradiction; - absent runtime evidence may use a known catalog requirement;
- no usable source becomes
missing_evidence; and - unknown routes and unsupported APIs remain blocking unknowns.
No source silently wins a disagreement.
The automatic runtime source is origin-bound: only a response to an exact
https://api.github.com request is eligible. A known GitHub route with no
runtime header remains eligible for the pinned-catalog fallback.
Observations for the same route are merged deterministically. The route stores the sorted unique set of scenario names that exercised it. A contradiction in any scenario blocks the aggregate contract.
Global solver
Routes are processed by canonical method/template order:
- start with the empty assignment;
- join every frontier candidate with every route alternative;
- use the maximum level for repeated permission names;
- deduplicate assignments;
- remove assignments dominated in every permission dimension; and
- fail if combination or frontier resource bounds are exceeded.
GitHub's mandatory metadata:read is supplied to the solver as a baseline.
Routes satisfied entirely by that baseline do not add metadata to
selectedPermissions.
For a new contract, the default selected sufficient contract uses:
- fewest write permissions;
- lowest total weight (
read = 1,write = 4); - fewest distinct permissions; and
- lexicographically smallest canonical assignment.
This is a deterministic risk policy, not universal optimality. Every
nondominated choice remains in permissionFrontier. A reviewer may explicitly
replace selectedPermissions with any one complete frontier assignment. On a
later check, that exact assignment is retained if it remains in the recomputed
frontier. Otherwise the deterministic default is proposed as a normal
review-required contract change; a noninteractive check never writes or
accepts it.
Contract schema v3
granttrace.lock.json stores:
- schema, tool, API, and checksummed catalog identity;
- sorted unique scenario names;
- sorted canonical route templates;
- canonical DNF requirements and evidence provenance;
- sorted unique scenario attribution for every route;
- exact evidence provenance used by each scenario on a shared route;
- observed selected permissions;
- the nondominated permission frontier;
- separately documented manual keeps; and
- safe unknown findings.
Unless the contract intentionally contains zero scenarios, every declared
scenario must appear in route or unknown attribution. A zero-scenario v3
contract must also contain zero routes, selected permissions, and unknowns,
with the single empty assignment in its frontier. It is the explicit reviewed
representation of retiring all observed coverage. Validated manual keeps remain
until explicitly removed and remain unproven retained access. Routes must be
unique. The frontier is recomputed from the stored routes during validation
and must match exactly. selectedPermissions must exactly equal one complete
assignment in that frontier.
Serialization uses stable object construction, two-space JSON indentation, and one trailing newline. It contains no timestamp, command, test path, working directory, commit, machine value, proof result, raw URL, owner, repository, resource ID, token, JWT, or private key.
Atomic writes create a sibling temporary file and rename it only after strict serialization succeeds.
Contract migration to v3
A valid v1 contract remains readable. V1 routes have no scenario attribution,
so the reader conservatively attaches every declared scenario to every route.
This is intentionally broad and is exposed as the structured migration ID
schema_v1_to_v3.
The migration:
- never guesses narrower route ownership;
- never silently writes the converted object;
- causes
checkto show a v1-to-v3 migration review; - preserves validated manual keeps; and
- blocks contract mutation and
proveuntil current observations are accepted as v3.
The deterministic migration path is:
record current named scenarios
-> granttrace check
-> review permission, route, and attribution changes
-> granttrace check --acceptReleased schema-v2 contracts remain readable in both forms: the current
scenarioEvidence shape and the older legacy-v2 shape without per-scenario
provenance. Before either form is migrated, the reader recomputes the frontier
and requires selectedPermissions to equal v2's deterministic default. A v2
artifact with any other selection is invalid; permission to choose any exact
frontier member begins with v3.
The current v2 shape is exposed as schema_v2_to_v3. The legacy shape is
conservatively expanded in memory and exposed as
legacy_schema_v2_to_v3. Neither migration silently writes. check exits 6
until the migration is reviewed and accepted, and frontier select, keep,
and prove remain blocked until a v3 contract is accepted. frontier list
remains read-only and may display the validated in-memory frontier.
Multi-scenario operations
Local observation files are bounded to 128 files, 10,000 observations, and 10 MiB aggregate input. Files are loaded in ASCII filename order.
An atomic .granttrace/active-operation lock prevents overlapping write
operations from racing contract, observation, report, or session updates. The
lock contains an owned 0600 owner record with a schema, process identifier,
and creation time. doctor --repair removes it only when that process is proven
gone. It may remove an empty lock only after a one-hour threshold, covering a
crash before the owner record was written. Live or unverifiable processes,
malformed records, unsafe modes, symlinks, and unknown contents fail closed.
scenario list reads and validates every recording. scenario remove NAME
removes only .granttrace/observations/NAME.ndjson; it does not edit the
accepted contract. The next check exposes the scenario, route, attribution,
and permission contraction for review. Removing the final recording is also a
reviewable change: acceptance writes the deterministic zero-scenario form while
preserving validated manual keeps.
For live proof, the accepted aggregate contract is sliced to the named scenario:
- only routes attributed to that scenario remain;
- each retained route is attributed only to that scenario;
- frontier permissions are solved again and the first candidate covered by the accepted aggregate selection becomes the scenario selection; and
- only unknowns belonging to that scenario remain.
Live observations must serialize exactly as that scenario slice after the same compatible frontier selection and validated manual keeps are applied.
Manual keeps
A manual keep is:
type ManualKeep = {
level: "read" | "write";
reason: string; // trimmed, 1–240 plain-text characters
};Keeps are canonicalized by permission name. A keep cannot duplicate access
already satisfied by selected permissions and cannot duplicate the mandatory
baseline. metadata is rejected by the CLI for that reason.
Reasons are committed review text: 1–240 plain-text characters without secrets, URLs, or personal identifiers. Control, format, or invisible characters and obvious token or private-key shapes are rejected before a reason can be displayed or stored.
Manual keeps are global to the contract and participate in every scenario's live token:
requested = max(scenario selected, manual keeps)
effective = max(requested, mandatory baseline)They do not participate in route solving and are never labeled observed or proven necessary. Proof reports include the reasoned keep map and separate selected, requested, mandatory, and effective assignments.
keep add and keep remove are explicit human mutations of the accepted
contract. check preserves valid existing keeps while rebuilding observed
evidence.
Contract diff and acceptance
Without --accept, every semantic contract difference exits 6. The review
includes:
- permission additions, escalations, reductions, and removals;
- scenario and route additions/removals;
- attribution additions/removals;
- DNF or evidence-provenance changes;
- manual-keep additions/removals/updates;
- tool, API, or catalog identity changes; and
- schema migration identifiers.
Unknown, malformed, unsupported, or contradictory evidence exits 7 before
acceptance. check --accept, or an explicit yes to the interactive record
prompt, writes the exact reviewed v3 contract atomically. Contract acceptance,
manual-keep mutation, and frontier selection are refused when CI is enabled
or GITHUB_ACTIONS=true, before acquiring the mutation lock or writing. There
is no interactive CI prompt and no noninteractive auto-acceptance.
Live proof state machine
prove NAME -- COMMAND ARGS (with legacy --scenario NAME compatibility)
implements:
strict accepted v3 contract validated
-> named scenario slice solved
-> every route and DNF rebound to the exact pinned catalog
-> guarded fixture configuration validated
-> requested = selected + manual keeps
-> one-repository token minted
-> effective = requested + mandatory baseline verified
-> automatic recorder preload injected into restricted child
-> restricted child run
-> scenario observations reproduced exactly
-> applicable negative controls run
-> cleanup reported independently
-> strict ephemeral report writtenCatalog rebinding completes before GrantTrace loads credentials or mints a token. The proof child runs directly without a shell, streams output, receives the automatic Node recorder preload, requires valid observations, and has a default 15-minute timeout bounded to 30 minutes. A timeout is indeterminate evidence, never a permission result. A terminal interrupt is remembered independently of the child's exit code and cannot become a pass if the child handles the signal and exits zero.
Its environment starts from an operating-system allowlist. Broker credentials,
existing GitHub tokens, HOME, NODE_OPTIONS, and arbitrary environment
variables are absent. The child receives only the restricted installation
token, recorder/session values, focused fixture coordinates, and allowed
system values. This is credential isolation, not an OS sandbox.
The report records sourceCommit = HEAD only when Git reports a clean index
and worktree. Dirty or unavailable provenance is serialized as
sourceCommit = null; the last commit is never used as a label for modified
source.
The raw token response is conclusive only if it reports:
effective permissions
= scenario-selected permissions
+ manual keeps
+ mandatory metadata:readIt must also report exactly one expected repository and an expiry 45–65 minutes in the future. Missing effective-permission evidence and every other difference block.
Negative controls
The framework currently defines:
| ID | Mode | Target | Removed permission |
|---|---|---|---|
issue-comments-read | read-only | list issue comments | issues |
issue-comment-create | mutating | create an issue comment | issues |
A control is applicable only when:
- its exact route is present in the scenario contract;
- the selected assignment includes
issues; - no
issuesmanual keep requires retention; and - removing
issuesmakes the target route's DNF unsatisfied.
The negative token is minted with that permission removed and the same strict one-repository/effective-scope validation.
Only an authorization failure counts as expected rejection. 401, 404,
429, rate-limit 403, 5xx, network failure, expiry, and indeterminate
errors retain distinct classes. Unexpected success always fails. For the
mutating control, an unexpected created comment is deleted with the positive
token; cleanup failure is reported separately and blocks success. Read-only
controls never require cleanup. Unsupported controls are marked not applicable.
Ephemeral proof report
.granttrace/reports/<scenario>.json uses strict schema v3 and stores only:
- schema/tool/API/catalog identity and source commit or
null; - scenario and deterministic aggregate contract hash;
- scenario-selected permissions, documented manual keeps, and requested, mandatory, and effective assignments;
- repository-scope and exact-contract booleans;
- the deterministically derived proof-strength value;
- safe child exit/signal/observation counts;
- positive-proof state;
- negative-control IDs, modes, removed permissions, states, and cleanup; and
- aggregate cleanup state.
It cannot contain credentials, commands, raw URLs, identities, responses, or
rich errors. Unknown fields fail validation. The report directory is 0700;
the file is 0600.
proofStrength is derived from the allowlisted phase data and cannot be chosen
independently:
not_establishedapplies unless positive reproduction passed, aggregate cleanup passed, and every built-in control ended inexpected_rejectionornot_applicable;restricted_scope_reproducedapplies to such a completed run when no selected permission was exercised by an applicable successful control;necessity_partially_testedapplies when successful controls removed at least one but fewer than all scenario-selected permission names; andnecessity_testedapplies when successful controls removed every scenario-selected permission name.
These four stable enum values describe permission-name removal tests. They do
not establish that a write level is minimal relative to read, because the
current controls remove the permission name rather than comparing access
levels.
Manual keeps and mandatory permissions are excluded from the denominator and
can never gain a necessity claim. Duplicate controls for the same removed
permission count once. A failed, indeterminate, unexpectedly successful,
uncleaned, or unfinished run must serialize not_established. The schema
rejects both stronger and weaker caller-supplied values.
Contract and observation inputs are size-bounded regular files. GrantTrace rejects symlinks and other nonregular types, uses a no-follow open where the platform supports it, and verifies that the file opened is the file that was inspected. Proof reports are validated, atomically written outputs.
Failure classes
unknown_route
unsupported_api
missing_permission_evidence
malformed_permission_evidence
evidence_contradiction
authentication_failure
authorization_failure
resource_not_found_or_hidden
rate_limited
github_unavailable
token_expired
instrumentation_failure
test_failure
test_flake_or_indeterminate
cleanup_failure
invalid_token_response
missing_effective_permissions
effective_permission_mismatch
unverified_repository_scope
configuration_failure
contract_mismatchA generic nonzero child exit is test_failure, never an inferred permission
rejection.
CLI exit codes
| Code | Meaning |
|---|---|
0 | Success |
2 | Invalid usage |
3 | Missing instrumentation or observations |
4 | Record-child test failure, timeout, or spawn failure |
5 | Invalid artifact, analysis, or live configuration |
6 | Contract review or migration required |
7 | Unknown, unsupported, malformed, or contradictory evidence |
8 | Proof, negative-control, or cleanup failure |
130 | Child interrupted by a terminal signal |