Output Projection and Omission Contracts

Tracker references: pm-p258tx, pm-cyrfjq, and pm-qhnq6t. The universal row and intent-budget contracts are tracked by pm-sb0tns, pm-cxr0jb, and pm-5t33or. Reachable budgets and cursor-chain amortization are tracked by pm-7hbfch, pm-yekkvt, and pm-sf31yl. Trustworthy collection selectors are tracked by pm-x710qm.

Agent Quick Context

Bounded reads must say what they withheld. Machine consumers should inspect omission_receipt before treating an absent field as absent project context:

{
  "omission_receipt": {
    "has_omissions": true,
    "omitted_field_group_count": 1,
    "omitted_field_groups": [{ "name": "provenance", "restore_with": "--full" }]
  }
}

The receipt is constant-sized per command. It does not grow with item count, history length, or workspace size. A complete projection still emits an explicit receipt with has_omissions: false, omitted_field_group_count: 0, and an empty omitted_field_groups list.

Mode-Paired Collections

Mutually exclusive output modes emit only their active row collection:

Command mode Active row key Withheld group Restore
activity --compact compact_activity provenance --full
activity --full activity none already complete
history (compact default) compact_history raw_history --full
history --full history none already complete

Inactive row keys are omitted, not zero-filled. This makes a wrong parser loud: reading .activity from compact activity now yields a missing key instead of a plausible empty result that contradicts count.

SDK authors can reuse createOutputOmissionReceipt, resolveModePairedOutputOmissionReceipt, and PM_MODE_PAIRED_OUTPUT_PROJECTION_CONTRACTS from @unbrained/pm-cli/sdk. The CLI uses the same declarations, so package integrations and built-in output cannot drift independently.

Universal Read Rows

Core read results expose a row_contract whether or not the current page has rows:

{
  "row_contract": {
    "command": "list",
    "row_kind": "collection",
    "row_keys": ["items"],
    "fields": "supported",
    "jq_selector": ".row_contract.row_keys[] as $key | getpath($key | split(\".\")) | if type == \"array\" then .[] else if type == \"object\" then to_entries[] else empty end end"
  }
}

The selector is identical for list aliases, context, next, search, activity, history, graph, health, aggregate, duplicates, stats, annotations (comments, notes, learnings), linked resources (files, docs), validation diagnostics, and command contracts. Commands with several collections declare every active dot-delimited path. This keeps nested dependency graph and relationship-context rows addressable as graph.nodes, graph.edges, context.nodes, and context.edges without duplicating them at the envelope root. Array collections produce their elements; object maps such as stats counts produce jq to_entries rows. Commands without a row collection, including a dependency tree or leaf get, declare row_kind: "none", an empty row_keys array, and omit jq_selector. The absence is therefore distinguishable from a legitimate empty collection. Validation declares both checks and warnings; contracts declares both command_summaries and commands, allowing either projection to stay machine-iterable without a command-specific selector. fields is always explicit, so an agent can distinguish a supported --fields projection from a command that intentionally owns a fixed row shape. NDJSON event streams do not carry an envelope and therefore do not publish a row contract.

SDK and package authors can import PM_READ_ROW_CONTRACTS, PM_READ_ROW_JQ_SELECTOR, and resolveReadRowContract from @unbrained/pm-cli/sdk. Existing package declarations are preserved only when command, row_kind, row_keys, fields, and the conditional jq_selector form a structurally valid row contract; malformed declarations are replaced by the canonical built-in contract when one applies.

Self-Describing SDK Projections

Built-ins and extensions can declare bounded shapes directly on their result:

const result = {
  projection: {
    mode: "summary",
    declared_field_groups: [
      { name: "evidence_rows", restore_with: "--full" },
      { name: "risk_rows", restore_with: "--include risk" },
    ],
    included_field_groups: ["risk_rows"],
  },
};

attachOutputOmissionReceipt validates this declaration and derives the same constant-sized receipt used by built-in commands. Invalid or incomplete declarations are ignored rather than producing an untruthful restore instruction. Package authors therefore own the names and restoration controls for their domain without adding package-specific logic to the CLI renderer.

The following bounded built-ins now use this shared declaration:

Command mode Withheld group Restore
deps --summary selected dependency tree, graph, or relationship context --full
graph --summary result_rows --full
validate --counts diagnostic_rows --full

Passing --full with the corresponding compact flag is a usage error. An explicit full request and the default full request produce the same payload shape; the flag exists as a machine-actionable restore instruction.

graph impact is bounded to ten rows by default, returns next_cursor when more affected nodes exist, and accepts --after <cursor> to resume in stable breadth-first order. graph impact --full is the explicit unbounded override; combining --full with --limit is rejected. A zero-row page remains resumable: when --limit 0 truncates reachable work, its cursor represents the root boundary so a later positive-limit request can retrieve the first row. Impact cursors bind the root and traversal semantics, not the page size, so a caller may deliberately raise or lower --limit when resuming.

For get, each independently selectable group uses its composable field selector as the restore instruction: --fields children, --fields claim_state, or --fields linked. Combine them in one selector when several groups are needed. This remains truthful for leaf items because an explicit children projection returns an empty rollup instead of silently conflating “no children” with “children were not requested.”

Intent Budgets

Built-in read intents apply valid command-specific defaults and disclose the resolved contract in context_intent:

{
  "context_intent": {
    "command": "list",
    "intent": "triage",
    "token_budget": 3200,
    "estimated_tokens": 1416,
    "within_budget": true,
    "degradation": "bounded_fields_and_rows",
    "declaration_feasible": true,
    "result_omitted": false
  }
}

Use context --for orient|handoff, get --for inspect, list --for triage, next --for execute, or search --for discover. Explicit caller projection options win over intent defaults, while the intent ceiling still applies. Selecting an intent is an explicit request for its bounded shape: get --for inspect defaults to standard depth. List and search derive their default page size from the effective token ceiling and a conservative per-row cost, so raising --token-budget increases useful rows instead of paying the same fixed envelope cost for every page. Context applies the same principle to its focus and activity limits so the built-in orientation declaration remains feasible on a large tracker. All five intent commands accept the same --token-budget override. Explicit --depth and --limit controls still win.

If a selected result exceeds its budget, long explanatory strings compact first, followed by deterministic root-row reduction that retains at least one useful row and reports budget_row_compaction. Only a result whose minimum useful projection cannot fit becomes budget_receipt_only. That receipt sets declaration_feasible: false, result_omitted: true, and within_budget: false; fitting the refusal envelope does not make the omitted result truthful. Recovery recommends increasing the ceiling or narrowing the request and never sends an agent to a potentially larger unprojected retry. Explicit overrides below 256 tokens are rejected because the minimum machine-readable receipt cannot fit; malformed or absent overrides retain the declared intent budget.

The first cursor page carries the complete projection, filtering, sorting, completeness, row, and omission contracts. Continuation pages replace those chain-invariant blocks with continuation_contract, which carries the cursor's query fingerprint and a compact restore instruction. Continuations retain only the rows and next cursor needed to advance the chain; page counts, total, truncation, timestamps, and the full intent receipt are referenced from the first page instead of being re-emitted. Calls without --for remain byte-compatible with the ordinary projection path apart from the universal row contract.

The mandatory calibration gate generates both a two-item workspace and a 2,243-item current-scale workspace. It validates all five intents, removes one receipt as an enforcement negative control, traverses every list and search cursor without duplicates or omissions, and reconstructs repeated first-page metadata as a continuation negative control. The checked-in report is scripts/release/context-intent-calibration.json.

Intent 2-item tokens / budget 2,243-item tokens / budget Current-scale rows Degradation
context:orient 735 / 2,400 1,028 / 2,400 3 bounded sections
get:inspect 401 / 3,200 415 / 3,200 item envelope standard item
list:triage 443 / 3,200 3,189 / 3,200 69 budget-derived rows
next:execute 395 / 1,200 1,171 / 1,200 14 budget-derived rows
search:discover 350 / 1,800 1,761 / 1,800 27 budget-derived rows

Whole-answer cursor cost is measured against the unprojected single call for the identical ordered row set:

Family Rows Pages Optimized bytes/row Optimized walk Repeated-metadata control Unbounded call Walk / unbounded
list:triage 1,998 31 172.93 345,519 B 387,489 B 1,558,741 B 0.2217
search:discover 1,998 66 202.50 404,591 B 470,046 B 1,681,847 B 0.2406

These are corpus-generated figures, not live tracker payloads. The generated corpus contains 2,243 items; the status:all query intentionally excludes 245 canceled fixtures under the command's current status-selection contract.

health --check-only defaults to a summary verdict: all checks still run, but passing check evidence bodies are empty. Use health --check-only --full when diagnostic evidence is required. Explicit --brief or --summary retains the existing fast check-only mode that skips expensive optional scans.

Completion Resolver Contract

resolveCompletionTimestamp accepts legacy metadata where all timestamp fields are optional. Its return is discriminated:

const completion = resolveCompletionTimestamp(item);
if (completion.resolved) {
  console.log(completion.timestamp, completion.source);
} else {
  console.log("No evidence-backed completion timestamp");
}

The resolved branch always carries timestamp, source, and fallback. The unresolved branch carries none of them, so TypeScript callers cannot accidentally attribute missing evidence to updated_at.

Compatibility

This is an intentional machine-output correction. Consumers that probed an inactive collection and received [] must branch on projection.mode or read the active row_contract.row_keys. Consumers that already used the populated key continue to receive the same rows.

Dependency token accounting includes the projection declaration and derived receipt. Reported usedTokens and truncation estimates therefore describe the final serialized result, not a pre-receipt intermediate.


Output_projection_contracts remote
Report an issue