diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index f5edc3b..3622b32 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -73,6 +73,13 @@ jobs: # a cancelled job should stop, not finish its remaining steps. if: ${{ !cancelled() }} run: bun test + - name: Verify the provision census matches the pinned corpus + # ir/census/provisions.yaml is generated from spec/acs and committed + # so its diff is reviewable. A submodule bump or a sources.yaml edit + # that nobody regenerated it for is a stale count, and a stale + # count is exactly what the census exists to make impossible. + if: ${{ !cancelled() }} + run: bun run ir census --check --quiet # The network half, deliberately separate. Both of these shallow-clone AGT # at agt.lock's PINNED ref -- an immutable commit, so neither can be broken diff --git a/README.md b/README.md index 0e5d4e7..bd85678 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ This is a demonstration and a measurement, not a product. The section [What this | Policy | `policy/lib/`, `policy/manifest.yaml`, `agt.lock` | AGT's stock policy bundle, byte-identical to the pinned upstream commit, plus one configuration file. | | Translation table | `mapping.yaml` | AGT intervention points to ACS methods. AGT verdicts to ACS decisions. How each ACS field is derived. | | Specification | `spec/acs/` | The ACS specification, pinned as a git submodule. | +| Normative IR | `ir/` | The spec's requirement catalog and censuses. Reads `spec/acs` and nothing else here; shares no code with the parts above. | ## What this project is, and is not @@ -439,6 +440,9 @@ Everything above runs with no configuration. These are the variables each proces | [`docs/shaping/acs-reference-impl-slices.md`](docs/shaping/acs-reference-impl-slices.md) | The ten slices, the risk table, and what each slice measured. Authoritative for slice scope | | `slices/v1/` to `slices/v10/` | One README per slice: what it delivered, what it did not, and the names it froze | | `docs/demos/v1-runbook.md` to `v9-runbook.md` | Real captured output for each slice's demo | +| [`docs/shaping/normative-ir-shaping.md`](docs/shaping/normative-ir-shaping.md) | The Normative IR work stream: requirements, shape E, affordances. Authoritative for the IR's requirements | +| [`docs/shaping/normative-ir-slices.md`](docs/shaping/normative-ir-slices.md) | The IR's eight slices. Authoritative for IR slice scope | +| `ir/slices/v1/` | One README per shipped IR slice | | [`spec/acs/docs/spec/conformance.md`](spec/acs/docs/spec/conformance.md) | The ACS profiles and the ACS-Core baseline this tree is measured against | | [`SECURITY.md`](SECURITY.md) | How to report a security issue | diff --git a/bun.lock b/bun.lock index cc2dc5a..e9d4796 100644 --- a/bun.lock +++ b/bun.lock @@ -35,6 +35,17 @@ "guardian": "workspace:*", }, }, + "ir": { + "name": "acs-ir", + "version": "0.1.0", + "bin": { + "acs-ir": "src/main.ts", + }, + "devDependencies": { + "bun-types": "^1.3.14", + "typescript": "^7.0.2", + }, + }, "packages/agt-bridge": { "name": "agt-bridge", "version": "0.1.0", @@ -137,6 +148,8 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + "acs-ir": ["acs-ir@workspace:ir"], + "agent-control-specification": ["agent-control-specification@0.3.1-beta.0", "", { "optionalDependencies": { "agent-control-specification-darwin-arm64": "0.3.1-beta.0", "agent-control-specification-darwin-x64": "0.3.1-beta.0", "agent-control-specification-linux-arm64-gnu": "0.3.1-beta.0", "agent-control-specification-linux-x64-gnu": "0.3.1-beta.0", "agent-control-specification-opa-darwin-arm64": "0.3.1-beta.0", "agent-control-specification-opa-darwin-x64": "0.3.1-beta.0", "agent-control-specification-opa-linux-arm64": "0.3.1-beta.0", "agent-control-specification-opa-linux-x64": "0.3.1-beta.0", "agent-control-specification-opa-win32-x64": "0.3.1-beta.0", "agent-control-specification-win32-x64-msvc": "0.3.1-beta.0" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.100.0", "@langchain/core": ">=1.1.0", "@openai/agents": ">=0.11.0", "langchain": ">=1.0.0" }, "optionalPeers": ["@anthropic-ai/sdk", "@langchain/core", "@openai/agents", "langchain"] }, "sha512-vD1N169F2YhTCLbF4u7hEV5xfmAY1vziq4bHL2xhBa5QNOhdeEzrTdOIshWZw46fxxPJzEYDjQyjSmI2NnHCuQ=="], "agent-control-specification-darwin-arm64": ["agent-control-specification-darwin-arm64@0.3.1-beta.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6kr1DAeFOBf/8Q7eNFqLtUoUfRqIDTqZ2QvkIvSKxDYh3iY2/8cizn0bzbJJofuba02aDV1oLEd5vvgcYkbWAg=="], diff --git a/docs/shaping/normative-ir-shaping.md b/docs/shaping/normative-ir-shaping.md index 4a56bc5..1cde936 100644 --- a/docs/shaping/normative-ir-shaping.md +++ b/docs/shaping/normative-ir-shaping.md @@ -119,35 +119,47 @@ Sibling to [`acs-reference-impl-shaping.md`](./acs-reference-impl-shaping.md). S Surveyed at ACS `c259f57` (v0.1.0, `version.txt` = `0.1.0`) — the pinned `spec/acs` submodule, tracking `GenAI-Security-Project/agent-control-standard`, mirrored at the user's fork `afogel/ACS_official`. -### Normative corpus and its density +🟡 **Re-surveyed at ACS `6fce2a0` (v0.1.2), the submodule's current pin, by V1's census** (`bun run ir census`; [`ir/slices/v1/`](../../ir/slices/v1/README.md)). Everything below still describes `c259f57` unless marked. What moved between the two commits, for the corpus this document reasons about: -| File | RFC 2119 keyword occurrences | +| Change at v0.1.2 | Effect on this document | |---|---| -| `docs/spec/instrument/specification.md` | 111 | -| `docs/spec/instrument/hooks.md` | 27 | -| `docs/spec/conformance.md` | 22 | -| `docs/spec/trace/extend_opentelemetry.md` | 8 | -| `docs/spec/trace/events.md` | 7 | -| `docs/spec/inspect/README.md` | 7 | -| `docs/concepts/agents.md` | 6 | -| `docs/concepts/README.md` | 3 | -| `docs/topics/ACS_in_action_example.md` | 2 | -| `docs/spec/instrument/extend_mcp.md` | 2 | -| `docs/concepts/session-lifecycle.md` | 2 | -| `docs/concepts/intent.md` | 2 | -| `docs/spec/inspect/extend_cyclonedx.md` | 1 | -| `docs/concepts/trust.md` | 1 | -| `docs/concepts/identity.md` | 1 | -| `docs/acs.md` | 1 | -| **Total** | **203** | - -By keyword: `MUST` 70, `MAY` 44, `SHOULD` 34, `MUST NOT` 23, `OPTIONAL` 21, `RECOMMENDED` 7, `REQUIRED` 4. No `SHALL` / `SHALL NOT` / `SHOULD NOT` anywhere. +| `## 9. Approver Model` heading added (`daf81ac`) | **Fact 1's defect is fixed upstream** and upstream Finding 1 in the slices doc is closed. Its consequence stands and is now demonstrated: the three §9 provisions' section anchors changed, which is exactly the drift a section-number key would suffer. The lines cited below as `specification.md:257`/`:259` are now `:262`/`:264`; `:241` is now `:244` | +| Two new pages, `docs/identity/overview.md` and `docs/identity/standards.md`, carrying 5 keyword occurrences in table cells | Corpus total is **208**, not 203. Both pages say of themselves *"It does not propose a wire format or normative requirements yet"*; their MUSTs restate §6.4 and their RECOMMENDED / REQUIRED quote RFC 6749. The source census declares them informative, so the unbound count that V2 burns down from is **197** | +| §5's hook table gained `skillRegister`, `skillLoad`, `skillUnload` rows; `conformance.md` gained a paragraph stating that nobody verifies a conformance claim in v0.1.0 | No keyword delta. The conformance paragraph is a candidate Exclusion and independent support for R0's motivation | +| `response-envelope.json` now makes `result` a `oneOf` over `AcsResult` and `handshake.json#/$defs/ServerHello`; every schema `$id` moved to the `genai-security-project.github.io` base; `acs_schema.json` moved into `v0.1.0/` | The *structural* specimen in the V2 table now has a schema constraint to cite by JSON Pointer (R5.2) rather than a prose sentence. Pinned subschema hashes (R2.8) must be taken at `6fce2a0`, not `c259f57` | +| Schema count | The "48 JSON Schema files" claim below was a miscount at either commit. There are **44** under `specification/v0.1.0/` at v0.1.2: 29 under `hooks/` (23 base plus 6 strict `*.acs-provenance.json`), 10 top-level, 2 `agbom/`, 1 `inspect/`, 2 `trace/` | + +### Normative corpus and its density + +| File | `c259f57` (v0.1.0) | 🟡 `6fce2a0` (v0.1.2) | +|---|---|---| +| `docs/spec/instrument/specification.md` | 111 | 111 | +| `docs/spec/instrument/hooks.md` | 27 | 27 | +| `docs/spec/conformance.md` | 22 | 22 | +| `docs/spec/trace/extend_opentelemetry.md` | 8 | 8 | +| `docs/spec/trace/events.md` | 7 | 7 | +| `docs/spec/inspect/README.md` | 7 | 7 | +| `docs/concepts/agents.md` | 6 | 6 | +| `docs/concepts/README.md` | 3 | 3 | +| 🟡 `docs/identity/overview.md` | — | 3 (informative) | +| `docs/topics/ACS_in_action_example.md` | 2 | 2 | +| `docs/spec/instrument/extend_mcp.md` | 2 | 2 | +| `docs/concepts/session-lifecycle.md` | 2 | 2 | +| `docs/concepts/intent.md` | 2 | 2 | +| 🟡 `docs/identity/standards.md` | — | 2 (informative) | +| `docs/spec/inspect/extend_cyclonedx.md` | 1 | 1 | +| `docs/concepts/trust.md` | 1 | 1 | +| `docs/concepts/identity.md` | 1 | 1 | +| `docs/acs.md` | 1 | 1 | +| **Total** | **203** | **208** | + +By keyword at `c259f57`: `MUST` 70, `MAY` 44, `SHOULD` 34, `MUST NOT` 23, `OPTIONAL` 21, `RECOMMENDED` 7, `REQUIRED` 4. No `SHALL` / `SHALL NOT` / `SHOULD NOT` anywhere. 🟡 At `6fce2a0` the five additions are `MUST` +3, `RECOMMENDED` +1, `REQUIRED` +1, all in the two informative identity pages. `concepts/provenance.md` and `concepts/intent.md` are cited as *"(normative)"* from `specification.md` §7 and §8.4 while carrying 2 and 1 keyword occurrences. **Keyword count is not a proxy for normative weight**, which is why R1.1 makes corpus membership a declaration and provision discovery a separate operation. ### What already exists, and must not be duplicated -`specification/v0.1.0/` ships **48 JSON Schema files**: `request-envelope.json`, `response-envelope.json`, `handshake.json`, `provenance.json`, `provenance-summary.json`, `context-entry.json`, `ask-details.json`, `defer-details.json`, `modifications.json`, 26 under `hooks/` (including 6 strict `*.acs-provenance.json` variants), 2 under `agbom/`, `inspect/format-mapping.json`, and `trace/{otel,ocsf}-mapping.json`. +`specification/v0.1.0/` ships **48 JSON Schema files** (🟡 corrected to **44** in the re-survey above; the breakdown here was miscounted): `request-envelope.json`, `response-envelope.json`, `handshake.json`, `provenance.json`, `provenance-summary.json`, `context-entry.json`, `ask-details.json`, `defer-details.json`, `modifications.json`, 26 under `hooks/` (including 6 strict `*.acs-provenance.json` variants), 2 under `agbom/`, `inspect/format-mapping.json`, and `trace/{otel,ocsf}-mapping.json`. The JSON-Schema layer of the four-layer architecture is **already built and shipped**. The IR cites it (R5), never restates it. @@ -159,7 +171,7 @@ The JSON-Schema layer of the four-layer architecture is **already built and ship | # | Fact | Consequence | |---|---|---| -| 1 | `specification.md` has **no `## 9.` heading**; §9.1 and §9.2 exist and are hyperlinked from §6 and §8.4, but §9's preamble sits under §8.6 | Section anchors are **not** a reliable key. A binding keyed on `#8…` would mis-attribute three provisions, one of which (`Approvers MUST NOT return ASK`) is a named security invariant. Confirms R2.1's "never a section number" | +| 1 | `specification.md` has **no `## 9.` heading**; §9.1 and §9.2 exist and are hyperlinked from §6 and §8.4, but §9's preamble sits under §8.6. 🟡 **Fixed upstream at v0.1.2** (`daf81ac` adds `## 9. Approver Model`) | Section anchors are **not** a reliable key. A binding keyed on `#8…` would mis-attribute three provisions, one of which (`Approvers MUST NOT return ASK`) is a named security invariant. Confirms R2.1's "never a section number". 🟡 The upstream fix is the demonstration: those three provisions' section anchors changed between the two pins while their text did not | | 2 | 🟡 **`attr_list` is enabled but disqualified** — measured in [X3](./spike-marker-span.md). Of its four paragraph forms, three render the braces as **visible page text**; only an own-line `{: #id }` creates an anchor, and it attaches to the whole block (`

` / ``) | Two independent disqualifications: block granularity cannot address §6.1's three-provision cell or §10.3's four-provision sentence, and a misplaced attribute list publishes `{ #acs-req-017 }` on the live site. Raw `` survived all 8 tested positions with no such failure mode. **This corrects the first pass, which claimed attr_list markers render as anchors** | | 3 | Normative content lives in **tables and registries with no RFC 2119 keyword**: §6's *Required fields* column, §6.1's *Required* column, §7's *Required* column, §10.1's algorithm-registry status column, §17/§17.1's error-code registry, §7.2's channel-to-trust mapping, the `DEFER` reason enum, `timeout_decision` default `deny`, `skew_window_ms` RECOMMENDED default `300000` | A keyword-anchored census is **necessary but not sufficient**. Also: markers must work inside table cells (E2.3) | | 4 | Conformance is **tiered** across 7 profiles, declared per-session in the handshake | A verdict without profile scope is meaningless. Profile is a first-class field | diff --git a/docs/shaping/normative-ir-slices.md b/docs/shaping/normative-ir-slices.md index 7ce77f9..243f828 100644 --- a/docs/shaping/normative-ir-slices.md +++ b/docs/shaping/normative-ir-slices.md @@ -64,13 +64,15 @@ So V1 is the wide census (Phase 0), V2–V6 are the deep vertical on ~20 semanti **Reference numbers to reproduce** (measured in X3, at `c259f57`): paragraph 145, list item 33, table cell 17, blockquote 8, total 203; zero in headings, code fences, or inline code spans. If V1's scan disagrees with those, V1's scan is wrong. +🟡 **Shipped** ([`ir/slices/v1/`](../../ir/slices/v1/README.md)). The scan reproduces X3 exactly at `c259f57` (203 in 132 blocks, 145 / 33 / 17 / 8). Against the current pin, `6fce2a0` (v0.1.2), it reports **208 in 134 blocks**, 145 / 33 / **22** / 8: the five additions are table cells in the two new `identity/` pages, which the source census declares informative because they say of themselves that they propose no normative requirements yet. So the burn-down V2 starts from is **197 unbound**, with 11 excluded by source status. The corpus is 38 documents: 19 normative, 18 informative, 1 editorial (`concepts/README.md`). All eight footers parsed, 22 pillar entries, none dangling. + **Wires to future slices:** `N52` also feeds `U32` (V3) and `U34` (V6); those columns render empty until then. --- ## V2: Twenty provisions, marked and indexed -**Demo:** `acs-ir markers apply && acs-ir extract && acs-ir render` produces a provision index of twenty provisions. Each carries its opaque ID, node type, level, bound actor, activating profile, evidence class, and verbatim text. Re-running `acs-ir census` shows the unbound count fall from 203 to 183. +**Demo:** `acs-ir markers apply && acs-ir extract && acs-ir render` produces a provision index of twenty provisions. Each carries its opaque ID, node type, level, bound actor, activating profile, evidence class, and verbatim text. Re-running `acs-ir census` shows the unbound count fall from 203 to 183 (🟡 from 197 to 177 at the v0.1.2 pin, see V1). **Specimen selection is the substance of this slice.** Twenty provisions chosen for **semantic diversity, not document order**, per the source's insistence. The kinds, mapped to concrete provisions (from the shaping doc's specimen table): @@ -576,6 +578,6 @@ Not slice work, but discovered by it and worth reporting to the ACS maintainers | # | Finding | Source | |---|---|---| -| 1 | `specification.md` has **no `## 9.` heading**. §9.1 and §9.2 exist and are linked from §6 and §8.4, but §9's preamble — approver authentication, Guardian identity verification, *"Approvers MUST NOT return ASK"* — is stranded under §8.6 *Chain head publication* | Shaping survey | +| 1 | ~~`specification.md` has **no `## 9.` heading**. §9.1 and §9.2 exist and are linked from §6 and §8.4, but §9's preamble — approver authentication, Guardian identity verification, *"Approvers MUST NOT return ASK"* — is stranded under §8.6 *Chain head publication*~~ 🟡 **Fixed upstream in v0.1.2** (`daf81ac`, *Give the approver model its own section heading*). Nothing to report | Shaping survey | | 2 | Three `concepts/` `(normative)` callouts are obligations on the Guardian, which `concepts/README.md:14`'s altitude rule places in the pillars | [X5](./spike-provision-taxonomy.md) | | 3 | `conformance.md` and `specification.md` §7 state the ACS-Provenance all-or-nothing rule in near-identical prose — a restatement pair the `README.md:33` migration would resolve | X3, X5 | diff --git a/ir/README.md b/ir/README.md new file mode 100644 index 0000000..9863b1b --- /dev/null +++ b/ir/README.md @@ -0,0 +1,40 @@ +# ACS Normative IR + +The requirement catalog for the Agent Control Standard: every normative provision in the pinned spec with a permanent ID, a declared type, and (where testable) a machine-readable predicate, plus the tooling that keeps prose and catalog in step. + +**Shaping:** [`docs/shaping/normative-ir-shaping.md`](../docs/shaping/normative-ir-shaping.md) is authoritative for requirements, the shape (E), and the affordances. [`docs/shaping/normative-ir-slices.md`](../docs/shaping/normative-ir-slices.md) is authoritative for slice scope. One README per shipped slice lives under [`slices/`](./slices/). + +## The boundary + +This tree reads `spec/acs` (the pinned submodule) and nothing else in the repository (R8.2). No import here reaches `packages/`, `hosts/`, `policy/`, or `mapping.yaml`, and nothing outside `ir/` imports from it. It is a workspace of its own so it can be lifted into the spec repository later (R8.3) without carrying reference-implementation choices with it. + +## Run + +``` +bun install +bun run ir census # writes ir/census/provisions.yaml and prints the report +bun run ir census --check # exits 1 if the committed census is stale; what CI runs +``` + +`acs-ir census --corpus

` points the census at another ACS checkout, which is how the tests compare the pinned corpus with the commit the shaping survey measured. + +## Layout + +| Path | Owner | What it is | +|---|---|---| +| `census/sources.yaml` | authored | The source census (S11): every document under `spec/acs/docs/`, its normative status, and what makes it normative. The corpus is declared here, never inferred from a grep (R1.1). | +| `census/provisions.yaml` | generated | The provision census (S11): every RFC 2119 occurrence, its block type, and whether it is bound, excluded with a reason, or still unbound (R1.3, R1.6). Never hand-edited. | +| `src/corpus.ts` | code | Locates the submodule, lists `docs/**/*.md`, reads the commit and version. | +| `src/markdown-blocks.ts` | code | Line-level block classifier: paragraph, list item, table cell, blockquote, heading, code fence. | +| `src/census/` | code | N10 to N14 and N19: the census runner, the source census check, the keyword sweep, the callout scan, and the footer-seeded dependency edges. | +| `src/render/census.ts` | code | N52: the four V1 report panels. | +| `src/main.ts` | code | The `acs-ir` command line. | +| `test/` | tests | Unit tests on a fixture corpus, plus the pinned corpus held to the survey's reference numbers. | + +## What exists so far + +| Slice | Status | Delivers | +|---|---|---| +| V1 | shipped | The corpus, inventoried: both censuses, every occurrence still unbound and listed. | +| V2 | next | Twenty provisions marked with invisible anchors and indexed; the generated/authored seam. | +| V3 to V8 | planned | Staleness propagation, spec-lint CI, compiled predicates with the differential oracle, the conformance report, the full conversion, upstreaming. | diff --git a/ir/census/provisions.yaml b/ir/census/provisions.yaml new file mode 100644 index 0000000..d66242a --- /dev/null +++ b/ir/census/provisions.yaml @@ -0,0 +1,3234 @@ +# GENERATED by `acs-ir census` -- do not edit. +# The provision census (S11): every RFC 2119 keyword occurrence in the declared +# corpus and what became of it. Regenerate with `bun run ir census`; CI runs +# `bun run ir census --check` and fails when this file is stale. +corpus: + version: 0.1.2 + commit: 6fce2a0a71ed6372ed575ce19934dd32220b38d9 + documents: 38 +totals: + occurrences: 208 + by_keyword: + "MUST NOT": 23 + MUST: 73 + "SHALL NOT": 0 + SHALL: 0 + "SHOULD NOT": 0 + SHOULD: 34 + "NOT RECOMMENDED": 0 + RECOMMENDED: 8 + MAY: 44 + OPTIONAL: 21 + REQUIRED: 5 + by_block_type: + paragraph: 145 + list_item: 33 + table_cell: 22 + blockquote: 8 + heading: 0 + code_fence: 0 + thematic_break: 0 + blocks_with_keywords: 134 + masked: 0 + bound: 0 + excluded: 11 + unbound: 197 +sources: + - path: README.md + status: informative + occurrences: 0 + by_block_type: + paragraph: 0 + list_item: 0 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 0 + normative_tags: 0 + footer_entries: 0 + - path: about.md + status: informative + occurrences: 0 + by_block_type: + paragraph: 0 + list_item: 0 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 0 + normative_tags: 0 + footer_entries: 0 + - path: acs.md + status: informative + occurrences: 1 + by_block_type: + paragraph: 0 + list_item: 1 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 1 + unbound: 0 + normative_tags: 0 + footer_entries: 0 + - path: concepts/README.md + status: editorial + occurrences: 3 + by_block_type: + paragraph: 0 + list_item: 3 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 3 + unbound: 0 + normative_tags: 2 + footer_entries: 0 + - path: concepts/agents.md + status: normative + occurrences: 6 + by_block_type: + paragraph: 3 + list_item: 0 + table_cell: 0 + blockquote: 3 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 6 + normative_tags: 2 + footer_entries: 4 + - path: concepts/capability.md + status: normative + occurrences: 0 + by_block_type: + paragraph: 0 + list_item: 0 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 0 + normative_tags: 0 + footer_entries: 3 + - path: concepts/identity.md + status: normative + occurrences: 1 + by_block_type: + paragraph: 1 + list_item: 0 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 1 + normative_tags: 1 + footer_entries: 4 + - path: concepts/intent.md + status: normative + occurrences: 2 + by_block_type: + paragraph: 0 + list_item: 0 + table_cell: 0 + blockquote: 2 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 2 + normative_tags: 2 + footer_entries: 4 + - path: concepts/provenance.md + status: normative + occurrences: 0 + by_block_type: + paragraph: 0 + list_item: 0 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 0 + normative_tags: 2 + footer_entries: 4 + - path: concepts/session-lifecycle.md + status: normative + occurrences: 2 + by_block_type: + paragraph: 0 + list_item: 0 + table_cell: 0 + blockquote: 2 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 2 + normative_tags: 1 + footer_entries: 4 + - path: concepts/skill.md + status: normative + occurrences: 0 + by_block_type: + paragraph: 0 + list_item: 0 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 0 + normative_tags: 0 + footer_entries: 3 + - path: concepts/trust.md + status: normative + occurrences: 1 + by_block_type: + paragraph: 0 + list_item: 0 + table_cell: 0 + blockquote: 1 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 1 + normative_tags: 2 + footer_entries: 4 + - path: identity/overview.md + status: informative + occurrences: 3 + by_block_type: + paragraph: 0 + list_item: 0 + table_cell: 3 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 3 + unbound: 0 + normative_tags: 0 + footer_entries: 0 + - path: identity/standards.md + status: informative + occurrences: 2 + by_block_type: + paragraph: 0 + list_item: 0 + table_cell: 2 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 2 + unbound: 0 + normative_tags: 0 + footer_entries: 0 + - path: spec/conformance.md + status: normative + occurrences: 22 + by_block_type: + paragraph: 18 + list_item: 4 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 22 + normative_tags: 0 + footer_entries: 0 + - path: spec/inspect/README.md + status: normative + occurrences: 7 + by_block_type: + paragraph: 7 + list_item: 0 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 7 + normative_tags: 0 + footer_entries: 0 + - path: spec/inspect/extend_cyclonedx.md + status: normative + occurrences: 1 + by_block_type: + paragraph: 0 + list_item: 1 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 1 + normative_tags: 0 + footer_entries: 0 + - path: spec/inspect/extend_spdx.md + status: normative + occurrences: 0 + by_block_type: + paragraph: 0 + list_item: 0 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 0 + normative_tags: 0 + footer_entries: 0 + - path: spec/inspect/extend_swid.md + status: normative + occurrences: 0 + by_block_type: + paragraph: 0 + list_item: 0 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 0 + normative_tags: 0 + footer_entries: 0 + - path: spec/instrument/README.md + status: informative + occurrences: 0 + by_block_type: + paragraph: 0 + list_item: 0 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 0 + normative_tags: 0 + footer_entries: 0 + - path: spec/instrument/a2a/extend_a2a.md + status: informative + occurrences: 0 + by_block_type: + paragraph: 0 + list_item: 0 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 0 + normative_tags: 0 + footer_entries: 0 + - path: spec/instrument/a2a/hooks/cancel_task_request.md + status: informative + occurrences: 0 + by_block_type: + paragraph: 0 + list_item: 0 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 0 + normative_tags: 0 + footer_entries: 0 + - path: spec/instrument/a2a/hooks/get_task_push_notification_config_request.md + status: informative + occurrences: 0 + by_block_type: + paragraph: 0 + list_item: 0 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 0 + normative_tags: 0 + footer_entries: 0 + - path: spec/instrument/a2a/hooks/get_task_request.md + status: informative + occurrences: 0 + by_block_type: + paragraph: 0 + list_item: 0 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 0 + normative_tags: 0 + footer_entries: 0 + - path: spec/instrument/a2a/hooks/resubscribe_to_task_request.md + status: informative + occurrences: 0 + by_block_type: + paragraph: 0 + list_item: 0 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 0 + normative_tags: 0 + footer_entries: 0 + - path: spec/instrument/a2a/hooks/send_message_request.md + status: informative + occurrences: 0 + by_block_type: + paragraph: 0 + list_item: 0 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 0 + normative_tags: 0 + footer_entries: 0 + - path: spec/instrument/a2a/hooks/set_task_push_notification_config_request.md + status: informative + occurrences: 0 + by_block_type: + paragraph: 0 + list_item: 0 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 0 + normative_tags: 0 + footer_entries: 0 + - path: spec/instrument/a2a/hooks/stream_message_request.md + status: informative + occurrences: 0 + by_block_type: + paragraph: 0 + list_item: 0 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 0 + normative_tags: 0 + footer_entries: 0 + - path: spec/instrument/extend_mcp.md + status: normative + occurrences: 2 + by_block_type: + paragraph: 2 + list_item: 0 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 2 + normative_tags: 0 + footer_entries: 0 + - path: spec/instrument/hooks.md + status: normative + occurrences: 27 + by_block_type: + paragraph: 27 + list_item: 0 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 27 + normative_tags: 0 + footer_entries: 0 + - path: spec/instrument/specification.md + status: normative + occurrences: 111 + by_block_type: + paragraph: 70 + list_item: 24 + table_cell: 17 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 111 + normative_tags: 10 + footer_entries: 0 + - path: spec/trace/OCSF/implementation_examples.md + status: informative + occurrences: 0 + by_block_type: + paragraph: 0 + list_item: 0 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 0 + normative_tags: 0 + footer_entries: 0 + - path: spec/trace/README.md + status: informative + occurrences: 0 + by_block_type: + paragraph: 0 + list_item: 0 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 0 + normative_tags: 0 + footer_entries: 0 + - path: spec/trace/events.md + status: normative + occurrences: 7 + by_block_type: + paragraph: 7 + list_item: 0 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 7 + normative_tags: 0 + footer_entries: 0 + - path: spec/trace/extend_ocsf.md + status: normative + occurrences: 0 + by_block_type: + paragraph: 0 + list_item: 0 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 0 + normative_tags: 0 + footer_entries: 0 + - path: spec/trace/extend_opentelemetry.md + status: normative + occurrences: 8 + by_block_type: + paragraph: 8 + list_item: 0 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 8 + normative_tags: 0 + footer_entries: 0 + - path: topics/ACS_in_action_example.md + status: informative + occurrences: 2 + by_block_type: + paragraph: 2 + list_item: 0 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 2 + unbound: 0 + normative_tags: 0 + footer_entries: 0 + - path: topics/core_concepts.md + status: informative + occurrences: 0 + by_block_type: + paragraph: 0 + list_item: 0 + table_cell: 0 + blockquote: 0 + heading: 0 + code_fence: 0 + thematic_break: 0 + by_node_type: + requirement: 0 + definition: 0 + invariant: 0 + exclusion: 0 + bound: 0 + excluded: 0 + unbound: 0 + normative_tags: 0 + footer_entries: 0 +normative_tags: + - source: concepts/README.md + line: 11 + kind: citation + title: "" + text: "- **Cross-cutting invariants** (true across pillars) are defined here and tagged **(normative)**. They belong above the pillars because that is where they are true. *e.g. \"`Intent.parsed` MUST NOT be modified by the LLM or by data crossing an untrusted channel.\"*" + - source: concepts/README.md + line: 31 + kind: citation + title: "" + text: Every page carries the canonical definition, any cross-cutting invariants tagged **(normative)**, and a **Referenced by** footer pointing into the pillars that consume the concept. The graph is navigable in both directions. + - source: concepts/agents.md + line: 15 + kind: callout + title: Decision logging + text: "**Decision logging (normative).** A Guardian MUST log every decision with its reasoning, the evaluator's model identifier, and confidence when available." + - source: concepts/agents.md + line: 21 + kind: callout + title: Approver authentication + text: "**Approver authentication (normative).** Approver authentication is REQUIRED. The Guardian MUST verify the Approver's identity against policy before honoring the returned decision." + - source: concepts/identity.md + line: 21 + kind: callout + title: No mandated mechanism + text: "**No mandated mechanism (normative).** ACS mandates no authentication mechanism. The mechanism in use is declared at handshake. Trust schemes (for example SPIFFE, OIDC, DID, organizational PKI, or quorum signing) are deployment-defined and stay off the core wire contract." + - source: concepts/intent.md + line: 13 + kind: callout + title: Intent immutability + text: "**Intent immutability (normative).** Once an Intent is established, `Intent.parsed` MUST NOT be modified by the runtime LLM, by tool outputs, or by any data crossing an untrusted channel. It may grow only through approver action via the ASK flow." + - source: concepts/intent.md + line: 21 + kind: callout + title: The only conformant path to widen Intent + text: "**The only conformant path to widen Intent (normative).** The sole mechanism for extending `Intent.parsed` within a session is an Approver's `intent_extension` returned via the ASK flow. Extensions are subject to the session's `scope_mode`; under `scope_mode: strict` a Guardian MUST NOT honor an extension that adds capabilities the deployment policy forbids in strict mode." + - source: concepts/provenance.md + line: 19 + kind: callout + title: Lineage spans derivation + text: "**Lineage spans derivation (normative).** When data is derived from other data, its `derived_from` lineage is the union of the lineage of its inputs. Summarization and compaction are derivations: the summary that comes out carries the combined lineage of everything that went in." + - source: concepts/provenance.md + line: 27 + kind: callout + title: Provenance is framework-assigned + text: "**Provenance is framework-assigned (normative).** The framework, not the LLM, assigns `origin`, `source_id`, and `derived_from`, based on the code path a value came through. These fields are never inferred by the model, never a content judgment, and never a producer claim." + - source: concepts/session-lifecycle.md + line: 23 + kind: callout + title: Intent derivation is auditable + text: "**Intent derivation is auditable (normative).** When a subagent is spawned, the audit chain MUST record how the subagent's `Intent.parsed` relates to the parent's: inherited in full, a strict subset, derived from a parent directive, or fresh. A Guardian MAY deny a spawn whose derivation would grant capabilities the parent's [Intent](./intent.md) does not authorize." + - source: concepts/trust.md + line: 25 + kind: callout + title: The rungs do not collapse + text: "**The rungs do not collapse (normative).** A Guardian MUST NOT treat an asserted fact as attested. The basis of a fact is part of the fact; relying on a fact above its actual basis is an error." + - source: concepts/trust.md + line: 27 + kind: callout + title: Deterministic attachment is not a producer claim + text: "**Deterministic attachment is not a producer claim (normative).** Where ACS specifies a deterministically attached fact (a Provenance record's `origin` is the canonical case), the framework assigns it from the code path the value came through, never the producer of the data and never the LLM. Such a fact is never a content judgment and never a producer claim." + - source: spec/instrument/specification.md + line: 73 + kind: heading + title: 4.1 Handshake failure (normative) + text: "### 4.1 Handshake failure (normative)" + - source: spec/instrument/specification.md + line: 142 + kind: heading + title: 6.3 MODIFY composition (normative) + text: "### 6.3 MODIFY composition (normative)" + - source: spec/instrument/specification.md + line: 155 + kind: heading + title: 6.4 Honoring decisions (normative) + text: "### 6.4 Honoring decisions (normative)" + - source: spec/instrument/specification.md + line: 165 + kind: citation + title: "" + text: The Provenance concept and its fields are defined in [Concepts › Provenance](../../concepts/provenance.md) (normative). This section specifies the wire shape and the v0.1 trust-classification stance. + - source: spec/instrument/specification.md + line: 226 + kind: heading + title: 8.2 Chain hashing (normative) + text: "### 8.2 Chain hashing (normative)" + - source: spec/instrument/specification.md + line: 242 + kind: citation + title: "" + text: "Intent is OPTIONAL and is defined in [Concepts › Intent](../../concepts/intent.md) (normative). Wire fields: `raw`, `parsed` (capability list), `parser_provenance` (REQUIRED if `parsed` present; `origin` MUST be `user_input`), `scope_mode`." + - source: spec/instrument/specification.md + line: 244 + kind: lead_in + title: Intent immutability enforcement + text: "**Intent immutability enforcement (normative).** The invariant is defined in [Concepts › Intent](../../concepts/intent.md): once established (via `sessionStart` or the first `agentTrigger` for the session), `Intent.parsed` is fixed and may grow only through an approver's `intent_extension` via the ASK flow (§9.1). The framework MUST enforce it: any attempt to modify `Intent.parsed` by the runtime LLM, by tool outputs, or by data crossing an `untrusted` channel MUST be ignored or rejected, and SHOULD be recorded as an audit event. This rule is load-bearing for IBAC's central security claim: the capability set is fixed before untrusted data enters and can grow only through explicit, audited approver action." + - source: spec/instrument/specification.md + line: 250 + kind: heading + title: 8.6 Chain head publication (normative) + text: "### 8.6 Chain head publication (normative)" + - source: spec/instrument/specification.md + line: 266 + kind: heading + title: 9.1 Intent extension via ASK (normative) + text: "### 9.1 Intent extension via ASK (normative)" + - source: spec/instrument/specification.md + line: 278 + kind: heading + title: 9.2 Approver-incapable clients (normative) + text: "### 9.2 Approver-incapable clients (normative)" +dependency_edges: + - from: concepts/agents.md + line: 35 + kind: pillar + pillar: Instrument + targets: + - doc: spec/instrument/specification.md + anchor: null + label: specification + external: false + exists: true + text: "**Instrument**, [specification](../spec/instrument/specification.md): the Observed Agent emits hooks; the Guardian returns dispositions; Approvers participate via the ASK flow (§9)." + - from: concepts/agents.md + line: 36 + kind: pillar + pillar: Trace + targets: + - doc: spec/trace/events.md + anchor: null + label: events + external: false + exists: true + text: "**Trace**, [events](../spec/trace/events.md): every event names the evaluator; Guardian decisions become spans and OCSF findings." + - from: concepts/agents.md + line: 37 + kind: pillar + pillar: Inspect + targets: + - doc: spec/inspect/README.md + anchor: null + label: AgBOM + external: false + exists: true + text: "**Inspect**, [AgBOM](../spec/inspect/README.md): the AgBOM is the Observed Agent's component graph." + - from: concepts/agents.md + line: 38 + kind: see_also + pillar: null + targets: + - doc: concepts/identity.md + anchor: null + label: Identity + external: false + exists: true + text: See also [Identity](./identity.md) for how each party's identity is established. + - from: concepts/capability.md + line: 27 + kind: pillar + pillar: Instrument + targets: + - doc: spec/instrument/specification.md + anchor: null + label: specification + external: false + exists: true + text: "**Instrument**, [specification](../spec/instrument/specification.md): the requested-capability check against `Intent.parsed`, exposed in `policy_data` on a mismatch." + - from: concepts/capability.md + line: 28 + kind: pillar + pillar: Inspect + targets: + - doc: spec/inspect/README.md + anchor: null + label: AgBOM + external: false + exists: true + text: "**Inspect**, [AgBOM](../spec/inspect/README.md): `tool.capability` and the `agent_capability` component type." + - from: concepts/capability.md + line: 29 + kind: see_also + pillar: null + targets: + - doc: concepts/intent.md + anchor: null + label: Intent + external: false + exists: true + - doc: concepts/identity.md + anchor: null + label: Identity + external: false + exists: true + - doc: concepts/agents.md + anchor: null + label: Agents + external: false + exists: true + text: See also [Intent](./intent.md), [Identity](./identity.md), and [Agents](./agents.md). + - from: concepts/identity.md + line: 33 + kind: pillar + pillar: Instrument + targets: + - doc: spec/instrument/specification.md + anchor: null + label: specification + external: false + exists: true + text: "**Instrument**, [specification](../spec/instrument/specification.md): `user_identity` at session start; Approver identity verification (§9); identity declared at handshake." + - from: concepts/identity.md + line: 34 + kind: pillar + pillar: Trace + targets: + - doc: spec/trace/extend_ocsf.md + anchor: null + label: extend OCSF + external: false + exists: true + text: "**Trace**, [extend OCSF](../spec/trace/extend_ocsf.md): agent vs human actors distinguished in emitted events." + - from: concepts/identity.md + line: 35 + kind: pillar + pillar: Inspect + targets: + - doc: spec/inspect/README.md + anchor: null + label: AgBOM + external: false + exists: true + text: "**Inspect**, [AgBOM](../spec/inspect/README.md): peer identity on `a2a_peer` components." + - from: concepts/identity.md + line: 36 + kind: see_also + pillar: null + targets: + - doc: concepts/agents.md + anchor: null + label: Agents + external: false + exists: true + - doc: concepts/capability.md + anchor: null + label: Capability + external: false + exists: true + - doc: concepts/trust.md + anchor: null + label: Trust basis + external: false + exists: true + text: See also [Agents](./agents.md), [Capability](./capability.md), and [Trust basis](./trust.md). + - from: concepts/intent.md + line: 33 + kind: pillar + pillar: Instrument + targets: + - doc: spec/instrument/specification.md + anchor: null + label: specification + external: false + exists: true + text: "**Instrument**, [specification](../spec/instrument/specification.md): Intent establishment and immutability enforcement (§8), the ASK and intent-extension mechanics (§9)." + - from: concepts/intent.md + line: 34 + kind: pillar + pillar: Trace + targets: + - doc: spec/trace/events.md + anchor: null + label: events + external: false + exists: true + text: "**Trace**, [events](../spec/trace/events.md): Intent recorded as decision context on emitted spans." + - from: concepts/intent.md + line: 35 + kind: pillar + pillar: Inspect + targets: + - doc: spec/inspect/README.md + anchor: null + label: AgBOM + external: false + exists: true + text: "**Inspect**, [AgBOM](../spec/inspect/README.md): the component graph reflects what the agent is provisioned to do, against which Intent is the authorized subset." + - from: concepts/intent.md + line: 36 + kind: see_also + pillar: null + targets: + - doc: concepts/capability.md + anchor: null + label: Capability + external: false + exists: true + - doc: concepts/session-lifecycle.md + anchor: null + label: Session lifecycle + external: false + exists: true + - doc: concepts/agents.md + anchor: null + label: Agents + external: false + exists: true + text: See also [Capability](./capability.md), [Session lifecycle](./session-lifecycle.md), and [Agents](./agents.md) (Approvers). + - from: concepts/provenance.md + line: 39 + kind: pillar + pillar: Instrument + targets: + - doc: spec/instrument/specification.md + anchor: null + label: specification + external: false + exists: true + - doc: spec/instrument/hooks.md + anchor: null + label: hooks + external: false + exists: true + text: "**Instrument**, [specification](../spec/instrument/specification.md): Provenance objects and framework assignment (§7), the `preCompact` laundering guard ([hooks](../spec/instrument/hooks.md))." + - from: concepts/provenance.md + line: 40 + kind: pillar + pillar: Trace + targets: + - doc: spec/trace/extend_ocsf.md + anchor: null + label: extend OCSF + external: false + exists: true + text: "**Trace**, [extend OCSF](../spec/trace/extend_ocsf.md): provenance recorded on emitted events." + - from: concepts/provenance.md + line: 41 + kind: pillar + pillar: Inspect + targets: + - doc: spec/inspect/README.md + anchor: null + label: AgBOM + external: false + exists: true + text: "**Inspect**, [AgBOM](../spec/inspect/README.md): `registration_provenance` records how each component entered the graph." + - from: concepts/provenance.md + line: 42 + kind: see_also + pillar: null + targets: + - doc: concepts/trust.md + anchor: null + label: Trust basis + external: false + exists: true + - doc: concepts/capability.md + anchor: null + label: Capability + external: false + exists: true + text: See also [Trust basis](./trust.md) and [Capability](./capability.md). + - from: concepts/session-lifecycle.md + line: 33 + kind: pillar + pillar: Instrument + targets: + - doc: spec/instrument/specification.md + anchor: null + label: specification + external: false + exists: true + - doc: spec/instrument/hooks.md + anchor: null + label: hooks + external: false + exists: true + text: "**Instrument**, [specification](../spec/instrument/specification.md): SessionContext chain (§8), the `steps/*` hooks, session/turn/subagent lifecycle hooks; [hooks](../spec/instrument/hooks.md)." + - from: concepts/session-lifecycle.md + line: 34 + kind: pillar + pillar: Trace + targets: + - doc: spec/trace/events.md + anchor: null + label: events + external: false + exists: true + text: "**Trace**, [events](../spec/trace/events.md): session start/end map to OCSF Authentication logon/logoff; subagent spawn opens a nested session." + - from: concepts/session-lifecycle.md + line: 35 + kind: pillar + pillar: Inspect + targets: + - doc: spec/inspect/README.md + anchor: null + label: AgBOM + external: false + exists: true + text: "**Inspect**, [AgBOM](../spec/inspect/README.md): the snapshot is emitted once per session before content-bearing hooks fire." + - from: concepts/session-lifecycle.md + line: 36 + kind: see_also + pillar: null + targets: + - doc: concepts/intent.md + anchor: null + label: Intent + external: false + exists: true + - doc: concepts/trust.md + anchor: null + label: Trust basis + external: false + exists: true + text: See also [Intent](./intent.md) and [Trust basis](./trust.md) (the chain hash is an attested rung). + - from: concepts/skill.md + line: 31 + kind: pillar + pillar: Instrument + targets: + - doc: spec/instrument/hooks.md + anchor: skillregister + label: "`skillRegister`, `skillLoad`, and `skillUnload` hooks" + external: false + exists: true + text: "**Instrument**: the [`skillRegister`, `skillLoad`, and `skillUnload` hooks](../spec/instrument/hooks.md#skillregister)." + - from: concepts/skill.md + line: 32 + kind: pillar + pillar: Inspect + targets: + - doc: spec/inspect/README.md + anchor: null + label: AgBOM component type + external: false + exists: true + text: "**Inspect**: the `skill` [AgBOM component type](../spec/inspect/README.md)." + - from: concepts/skill.md + line: 33 + kind: see_also + pillar: null + targets: + - doc: concepts/capability.md + anchor: null + label: Capability + external: false + exists: true + - doc: concepts/provenance.md + anchor: null + label: Provenance + external: false + exists: true + - doc: concepts/trust.md + anchor: null + label: Trust basis + external: false + exists: true + text: See also [Capability](./capability.md), [Provenance](./provenance.md), and [Trust basis](./trust.md). + - from: concepts/trust.md + line: 51 + kind: pillar + pillar: Instrument + targets: + - doc: spec/instrument/specification.md + anchor: null + label: specification + external: false + exists: true + - doc: spec/conformance.md + anchor: null + label: conformance + external: false + exists: true + text: "**Instrument**, [specification](../spec/instrument/specification.md): framework-assigned Provenance fields (§7), cryptographic signatures and replay protection (§10), the SessionContext hash chain (§8); [conformance](../spec/conformance.md) for ACS-Crypto, ACS-Audit, ACS-Provenance." + - from: concepts/trust.md + line: 52 + kind: pillar + pillar: Trace + targets: + - doc: spec/trace/events.md + anchor: null + label: events + external: false + exists: true + text: "**Trace**, [events](../spec/trace/events.md): events default to the asserted rung; ACS-Crypto signing elevates them." + - from: concepts/trust.md + line: 53 + kind: pillar + pillar: Inspect + targets: + - doc: spec/inspect/README.md + anchor: null + label: AgBOM + external: false + exists: true + text: "**Inspect**, [AgBOM](../spec/inspect/README.md): `registration_provenance` as a deterministic attachment on each component." + - from: concepts/trust.md + line: 54 + kind: see_also + pillar: null + targets: + - doc: concepts/provenance.md + anchor: null + label: Provenance + external: false + exists: true + - doc: concepts/intent.md + anchor: null + label: Intent + external: false + exists: true + text: See also [Provenance](./provenance.md) (the primary input to the lower rungs) and [Intent](./intent.md) (an asserted fact until attested). +dependency_audit: + entries_without_depending_provision: 22 + pages_without_footer: [] + dangling_targets: [] +occurrences: + - source: acs.md + line: 14 + column: 116 + keyword: OPTIONAL + block_type: list_item + block: 9 + context: …`Provenance` objects with `origin`, `source_id`, `derived_from`, and an OPTIONAL wire-format `trust` enum that obeys the monotonicity rule. + binding: + kind: excluded + reason: informative_source + - source: concepts/README.md + line: 11 + column: 189 + keyword: MUST NOT + block_type: list_item + block: 6 + context: "…the pillars because that is where they are true. *e.g. \"`Intent.parsed` MUST NOT be modified by the LLM or by data crossing an untrusted channel.\"*" + binding: + kind: excluded + reason: editorial_source + - source: concepts/README.md + line: 12 + column: 110 + keyword: MUST + block_type: list_item + block: 7 + context: "…thin one pillar's enforcement) stay in that pillar. *e.g. \"the Guardian MUST verify approver identity,\" \"a client declaring `provenance_producer: no…" + binding: + kind: excluded + reason: editorial_source + - source: concepts/README.md + line: 12 + column: 190 + keyword: MUST + block_type: list_item + block: 7 + context: "…ify approver identity,\" \"a client declaring `provenance_producer: none` MUST be refused at handshake.\"*" + binding: + kind: excluded + reason: editorial_source + - source: concepts/agents.md + line: 15 + column: 48 + keyword: MUST + block_type: blockquote + block: 8 + context: "> **Decision logging (normative).** A Guardian MUST log every decision with its reasoning, the evaluator's model identifier…" + binding: + kind: unbound + - source: concepts/agents.md + line: 19 + column: 124 + keyword: MAY + block_type: paragraph + block: 10 + context: …outside standing policy and an `ask` disposition is raised. An Approver MAY be human, agent, or service. It receives an ACS-shaped request and retu… + binding: + kind: unbound + - source: concepts/agents.md + line: 21 + column: 71 + keyword: REQUIRED + block_type: blockquote + block: 11 + context: "> **Approver authentication (normative).** Approver authentication is REQUIRED. The Guardian MUST verify the Approver's identity against policy before…" + binding: + kind: unbound + - source: concepts/agents.md + line: 21 + column: 94 + keyword: MUST + block_type: blockquote + block: 11 + context: …cation (normative).** Approver authentication is REQUIRED. The Guardian MUST verify the Approver's identity against policy before honoring the retur… + binding: + kind: unbound + - source: concepts/agents.md + line: 23 + column: 22 + keyword: MAY + block_type: paragraph + block: 12 + context: The Approver's grant MAY extend `Intent.parsed` through an `intent_extension` (see [Intent](./in… + binding: + kind: unbound + - source: concepts/agents.md + line: 23 + column: 146 + keyword: MUST NOT + block_type: paragraph + block: 12 + context: "…ion` (see [Intent](./intent.md)). In v0.1 this is single-hop: Approvers MUST NOT themselves return `ask`." + binding: + kind: unbound + - source: concepts/identity.md + line: 13 + column: 35 + keyword: MUST NOT + block_type: paragraph + block: 7 + context: "Three identities are distinct and MUST NOT be conflated:" + binding: + kind: unbound + - source: concepts/intent.md + line: 13 + column: 87 + keyword: MUST NOT + block_type: blockquote + block: 7 + context: …utability (normative).** Once an Intent is established, `Intent.parsed` MUST NOT be modified by the runtime LLM, by tool outputs, or by any data crossin… + binding: + kind: unbound + - source: concepts/intent.md + line: 21 + column: 282 + keyword: MUST NOT + block_type: blockquote + block: 11 + context: "…ct to the session's `scope_mode`; under `scope_mode: strict` a Guardian MUST NOT honor an extension that adds capabilities the deployment policy forbids…" + binding: + kind: unbound + - source: concepts/session-lifecycle.md + line: 23 + column: 95 + keyword: MUST + block_type: blockquote + block: 12 + context: "…is auditable (normative).** When a subagent is spawned, the audit chain MUST record how the subagent's `Intent.parsed` relates to the parent's: inhe…" + binding: + kind: unbound + - source: concepts/session-lifecycle.md + line: 23 + column: 257 + keyword: MAY + block_type: blockquote + block: 12 + context: …a strict subset, derived from a parent directive, or fresh. A Guardian MAY deny a spawn whose derivation would grant capabilities the parent's [In… + binding: + kind: unbound + - source: concepts/trust.md + line: 25 + column: 57 + keyword: MUST NOT + block_type: blockquote + block: 13 + context: "> **The rungs do not collapse (normative).** A Guardian MUST NOT treat an asserted fact as attested. The basis of a fact is part of the…" + binding: + kind: unbound + - source: identity/overview.md + line: 124 + column: 1672 + keyword: MUST + block_type: table_cell + block: 70 + context: "…t/specification.md#64-honoring-decisions-normative): the Observed Agent MUST wait for the Guardian's decision up to the negotiated timeout, MUST app…" + binding: + kind: excluded + reason: informative_source + - source: identity/overview.md + line: 124 + column: 1740 + keyword: MUST + block_type: table_cell + block: 70 + context: …ent MUST wait for the Guardian's decision up to the negotiated timeout, MUST apply it, and MUST record every fail-open proceed as an audit event. No… + binding: + kind: excluded + reason: informative_source + - source: identity/overview.md + line: 124 + column: 1759 + keyword: MUST + block_type: table_cell + block: 70 + context: …he Guardian's decision up to the negotiated timeout, MUST apply it, and MUST record every fail-open proceed as an audit event. Note the spec's curre… + binding: + kind: excluded + reason: informative_source + - source: identity/standards.md + line: 155 + column: 135 + keyword: RECOMMENDED + block_type: table_cell + block: 107 + context: …tps://www.rfc-editor.org/rfc/rfc6749#section-4.2.2) leaves `expires_in` RECOMMENDED, not REQUIRED, and 60-minute defaults are common in production. + binding: + kind: excluded + reason: informative_source + - source: identity/standards.md + line: 155 + column: 152 + keyword: REQUIRED + block_type: table_cell + block: 107 + context: …tor.org/rfc/rfc6749#section-4.2.2) leaves `expires_in` RECOMMENDED, not REQUIRED, and 60-minute defaults are common in production. + binding: + kind: excluded + reason: informative_source + - source: spec/conformance.md + line: 11 + column: 12 + keyword: MAY + block_type: paragraph + block: 6 + context: A Guardian MAY refuse a session if the client does not declare a profile the Guardian'… + binding: + kind: unbound + - source: spec/conformance.md + line: 11 + column: 152 + keyword: MAY + block_type: paragraph + block: 6 + context: …ardian's policy requires (e.g. a Guardian whose policy needs provenance MAY refuse a client that does not declare `acs-provenance`). + binding: + kind: unbound + - source: spec/conformance.md + line: 15 + column: 32 + keyword: MUST + block_type: paragraph + block: 8 + context: "A v0.1.0-conformant deployment MUST implement ACS-Core. ACS-Core comprises:" + binding: + kind: unbound + - source: spec/conformance.md + line: 19 + column: 387 + keyword: SHOULD + block_type: list_item + block: 11 + context: …`skillRegister`/`skillLoad`/`skillUnload`) are normatively defined and SHOULD be implemented when the harness can observe the corresponding event; th… + binding: + kind: unbound + - source: spec/conformance.md + line: 19 + column: 566 + keyword: SHOULD + block_type: list_item + block: 11 + context: …via the handshake. A deployment whose AgBOM includes `skill` components SHOULD emit the skill lifecycle hooks, so a Guardian is not blind to a composi… + binding: + kind: unbound + - source: spec/conformance.md + line: 22 + column: 91 + keyword: MUST + block_type: list_item + block: 14 + context: …ion** — `request_id` (UUID) and `timestamp` on every request; Guardians MUST reject replays per [§10.3](./instrument/specification.md#103-replay-pro… + binding: + kind: unbound + - source: spec/conformance.md + line: 24 + column: 45 + keyword: MUST + block_type: list_item + block: 16 + context: "- **Decision honoring**: the Observed Agent MUST wait for the Guardian's decision up to the negotiated timeout and apply…" + binding: + kind: unbound + - source: spec/conformance.md + line: 28 + column: 186 + keyword: SHOULD + block_type: paragraph + block: 19 + context: …m signatures, or `request_hash` on ContextEntry (`request_hash` remains SHOULD). It DOES require the baseline signature (§10) and decision honoring (§… + binding: + kind: unbound + - source: spec/conformance.md + line: 28 + column: 362 + keyword: OPTIONAL + block_type: paragraph + block: 19 + context: …validate hook payloads against the base schemas, where `provenance` is OPTIONAL; field-level Provenance is added by the ACS-Provenance profile. + binding: + kind: unbound + - source: spec/conformance.md + line: 36 + column: 112 + keyword: MUST + block_type: paragraph + block: 23 + context: "…per [Trace Events](./trace/events.md). A deployment claiming ACS-Trace MUST:" + binding: + kind: unbound + - source: spec/conformance.md + line: 42 + column: 97 + keyword: MUST NOT + block_type: paragraph + block: 27 + context: …that need cross-vendor observability or SIEM integration. Trace events MUST NOT block enforcement. + binding: + kind: unbound + - source: spec/conformance.md + line: 46 + column: 100 + keyword: MUST + block_type: paragraph + block: 29 + context: "…n per [Inspect](./inspect/README.md). A deployment claiming ACS-Inspect MUST:" + binding: + kind: unbound + - source: spec/conformance.md + line: 59 + column: 195 + keyword: MUST + block_type: paragraph + block: 36 + context: "…t claiming ACS-Provenance runs `provenance_producer: deterministic` and MUST attach a Provenance object to every data-bearing field in every hook pa…" + binding: + kind: unbound + - source: spec/conformance.md + line: 61 + column: 33 + keyword: OPTIONAL + block_type: paragraph + block: 37 + context: The wire-format `trust` enum is OPTIONAL in v0.1; the v0.1 expected practice is for Guardians to derive trust fr… + binding: + kind: unbound + - source: spec/conformance.md + line: 61 + column: 265 + keyword: MUST + block_type: paragraph + block: 37 + context: …dor Guardian implementations that elect to populate `trust` on the wire MUST enforce the monotonicity rule on `agent_generated` trust and SHOULD use… + binding: + kind: unbound + - source: spec/conformance.md + line: 61 + column: 331 + keyword: SHOULD + block_type: paragraph + block: 37 + context: …wire MUST enforce the monotonicity rule on `agent_generated` trust and SHOULD use the default channel-to-trust mapping ([§7.2](./instrument/specifica… + binding: + kind: unbound + - source: spec/conformance.md + line: 67 + column: 119 + keyword: MUST + block_type: paragraph + block: 40 + context: …e baseline's replay-protection fields. A deployment claiming ACS-Crypto MUST support at least `ML-DSA-65` (RECOMMENDED primary) and SHOULD support `… + binding: + kind: unbound + - source: spec/conformance.md + line: 67 + column: 154 + keyword: RECOMMENDED + block_type: paragraph + block: 40 + context: …ds. A deployment claiming ACS-Crypto MUST support at least `ML-DSA-65` (RECOMMENDED primary) and SHOULD support `SLH-DSA-128s` as an algorithmic-diversity… + binding: + kind: unbound + - source: spec/conformance.md + line: 67 + column: 179 + keyword: SHOULD + block_type: paragraph + block: 40 + context: …ACS-Crypto MUST support at least `ML-DSA-65` (RECOMMENDED primary) and SHOULD support `SLH-DSA-128s` as an algorithmic-diversity backup. Hybrid compo… + binding: + kind: unbound + - source: spec/conformance.md + line: 67 + column: 320 + keyword: OPTIONAL + block_type: paragraph + block: 40 + context: …rid composites (`ML-DSA-65+ECDSA-P256`, `ML-DSA-65+RSA-PSS-SHA256`) are OPTIONAL for transitional deployments. + binding: + kind: unbound + - source: spec/conformance.md + line: 75 + column: 89 + keyword: MUST + block_type: paragraph + block: 44 + context: …audit chain beyond ACS-Core's baseline. A deployment claiming ACS-Audit MUST populate `request_hash` (lowercase-hex SHA-256 of JCS-canonicalized req… + binding: + kind: unbound + - source: spec/conformance.md + line: 75 + column: 294 + keyword: SHOULD + block_type: paragraph + block: 44 + context: …mmits to request content, not just step metadata. ACS-Audit deployments SHOULD also populate `timestamp` and `provenance_summary` on every ContextEntr… + binding: + kind: unbound + - source: spec/inspect/README.md + line: 10 + column: 223 + keyword: MUST + block_type: paragraph + block: 5 + context: …nventory (e.g. banning a model or tool at the boundary), the deployment MUST implement ACS-Inspect. + binding: + kind: unbound + - source: spec/inspect/README.md + line: 21 + column: 223 + keyword: MAY + block_type: paragraph + block: 17 + context: …evant history of the session. Decisions are normally `allow`; Guardians MAY return `deny` to refuse a session whose component graph contains a bann… + binding: + kind: unbound + - source: spec/inspect/README.md + line: 42 + column: 17 + keyword: SHOULD + block_type: paragraph + block: 49 + context: Every component SHOULD carry `registration_provenance` (who declared it — framework / configur… + binding: + kind: unbound + - source: spec/inspect/README.md + line: 42 + column: 236 + keyword: MUST + block_type: paragraph + block: 49 + context: …me lineage system as data flow. Deployments claiming **ACS-Provenance** MUST populate `registration_provenance` on every component. + binding: + kind: unbound + - source: spec/inspect/README.md + line: 54 + column: 12 + keyword: MAY + block_type: paragraph + block: 64 + context: A Guardian MAY request a specific serialization in the handshake's AgBOM negotiation (… + binding: + kind: unbound + - source: spec/inspect/README.md + line: 58 + column: 39 + keyword: MUST + block_type: paragraph + block: 66 + context: "A deployment claiming **ACS-Inspect** MUST:" + binding: + kind: unbound + - source: spec/inspect/README.md + line: 63 + column: 60 + keyword: MUST + block_type: paragraph + block: 69 + context: "A deployment claiming **ACS-Inspect-Dynamic** additionally MUST:" + binding: + kind: unbound + - source: spec/inspect/extend_cyclonedx.md + line: 74 + column: 36 + keyword: MAY + block_type: list_item + block: 34 + context: "- For `agbom/changed`, deployments MAY emit a CycloneDX VEX-style diff or simply emit a fresh full serializati…" + binding: + kind: unbound + - source: spec/instrument/extend_mcp.md + line: 12 + column: 13 + keyword: MAY + block_type: paragraph + block: 7 + context: Deployments MAY collapse MCP `tools/call` traffic into the generic `steps/toolCallReque… + binding: + kind: unbound + - source: spec/instrument/extend_mcp.md + line: 12 + column: 173 + keyword: SHOULD + block_type: paragraph + block: 7 + context: …toolCallResult` hooks when tool-level policy is sufficient. Deployments SHOULD use `protocols/MCP/*` when policy needs MCP-level distinctions that gen… + binding: + kind: unbound + - source: spec/instrument/hooks.md + line: 74 + column: 40 + keyword: MAY + block_type: paragraph + block: 111 + context: "**Decision:** ALLOW / DENY. A Guardian MAY refuse a session whose identity, policy mode, or platform fails policy…" + binding: + kind: unbound + - source: spec/instrument/hooks.md + line: 76 + column: 48 + keyword: MAY + block_type: paragraph + block: 112 + context: A deployment that does not emit `sessionStart` MAY allow the Guardian to implicitly initialize the chain at the first cont… + binding: + kind: unbound + - source: spec/instrument/hooks.md + line: 88 + column: 47 + keyword: MAY + block_type: paragraph + block: 118 + context: "**Decision:** ALLOW / DENY / MODIFY. Guardian MAY rewrite the trigger payload (e.g. redact PII) before activation." + binding: + kind: unbound + - source: spec/instrument/hooks.md + line: 102 + column: 46 + keyword: MAY + block_type: paragraph + block: 125 + context: "**Decision:** Decision-eligible — a Guardian MAY deny to block the turn from starting — but most deployments will return…" + binding: + kind: unbound + - source: spec/instrument/hooks.md + line: 114 + column: 47 + keyword: MAY + block_type: paragraph + block: 131 + context: "**Decision:** ALLOW / DENY / MODIFY. Guardian MAY redact content before delivery to the agent." + binding: + kind: unbound + - source: spec/instrument/hooks.md + line: 138 + column: 47 + keyword: MAY + block_type: paragraph + block: 143 + context: "**Decision:** ALLOW / DENY / MODIFY. Guardian MAY redact retrieved content before injection into the agent context." + binding: + kind: unbound + - source: spec/instrument/hooks.md + line: 172 + column: 12 + keyword: MUST + block_type: paragraph + block: 160 + context: Frameworks MUST fire `toolCallRequest` for every action that escapes the agent's reason… + binding: + kind: unbound + - source: spec/instrument/hooks.md + line: 200 + column: 43 + keyword: MAY + block_type: paragraph + block: 174 + context: "**Decision:** Decision-eligible. Guardian MAY return DENY to block compaction (e.g. because deployment policy disallo…" + binding: + kind: unbound + - source: spec/instrument/hooks.md + line: 210 + column: 75 + keyword: MUST + block_type: paragraph + block: 179 + context: …Payload:** resulting `summary` content with `provenance` whose `origin` MUST be `agent_generated` and whose `derived_from` MUST equal the union of `… + binding: + kind: unbound + - source: spec/instrument/hooks.md + line: 210 + column: 126 + keyword: MUST + block_type: paragraph + block: 179 + context: …ance` whose `origin` MUST be `agent_generated` and whose `derived_from` MUST equal the union of `provenance_id`s of every entry in `entries_compacte… + binding: + kind: unbound + - source: spec/instrument/hooks.md + line: 212 + column: 83 + keyword: MAY + block_type: paragraph + block: 180 + context: …:** Not decision-eligible — compaction has already occurred. A Guardian MAY return MODIFY (rewrite the summary, e.g. to redact a region the policy… + binding: + kind: unbound + - source: spec/instrument/hooks.md + line: 212 + column: 178 + keyword: MAY + block_type: paragraph + block: 180 + context: …ite the summary, e.g. to redact a region the policy can't compact), but MAY NOT return DENY. The audit chain MUST record the post-compact state reg… + binding: + kind: unbound + - source: spec/instrument/hooks.md + line: 212 + column: 215 + keyword: MUST + block_type: paragraph + block: 180 + context: …ion the policy can't compact), but MAY NOT return DENY. The audit chain MUST record the post-compact state regardless. + binding: + kind: unbound + - source: spec/instrument/hooks.md + line: 226 + column: 43 + keyword: MAY + block_type: paragraph + block: 187 + context: "**Decision:** Decision-eligible. Guardian MAY DENY — refuse the subagent spawn, e.g. because the `intent_derivation`…" + binding: + kind: unbound + - source: spec/instrument/hooks.md + line: 252 + column: 40 + keyword: MAY + block_type: paragraph + block: 200 + context: "**Decision:** ALLOW / DENY. A Guardian MAY deny registration; a denied skill MUST NOT become eligible to load. A G…" + binding: + kind: unbound + - source: spec/instrument/hooks.md + line: 252 + column: 78 + keyword: MUST NOT + block_type: paragraph + block: 200 + context: …ision:** ALLOW / DENY. A Guardian MAY deny registration; a denied skill MUST NOT become eligible to load. A Guardian SHOULD compare `declared_capabiliti… + binding: + kind: unbound + - source: spec/instrument/hooks.md + line: 252 + column: 123 + keyword: SHOULD + block_type: paragraph + block: 200 + context: …gistration; a denied skill MUST NOT become eligible to load. A Guardian SHOULD compare `declared_capabilities` against the union of capabilities the c… + binding: + kind: unbound + - source: spec/instrument/hooks.md + line: 252 + column: 226 + keyword: MAY + block_type: paragraph + block: 200 + context: …lities` against the union of capabilities the composed tools expose and MAY deny over-broad declarations. + binding: + kind: unbound + - source: spec/instrument/hooks.md + line: 262 + column: 136 + keyword: MUST + block_type: paragraph + block: 205 + context: …o the Guardian binds the activation to an approved registration. A load MUST be correlatable to a prior approved `skillRegister` for the same `(skil… + binding: + kind: unbound + - source: spec/instrument/hooks.md + line: 262 + column: 352 + keyword: SHOULD + block_type: paragraph + block: 205 + context: "…ion, or whose digest differs from the approved one, is unverifiable and SHOULD be denied. Without this binding the gate is bypassable: a framework cou…" + binding: + kind: unbound + - source: spec/instrument/hooks.md + line: 262 + column: 541 + keyword: MAY + block_type: paragraph + block: 205 + context: …t was never registered, or whose registration was denied. The framework MAY send a `digest_verified` hint, but a Guardian verifies the binding itse… + binding: + kind: unbound + - source: spec/instrument/hooks.md + line: 268 + column: 40 + keyword: SHOULD + block_type: paragraph + block: 208 + context: "**Decision:** ALLOW / DENY. A Guardian SHOULD deny a load it cannot correlate to an approved `skillRegister` for the…" + binding: + kind: unbound + - source: spec/instrument/hooks.md + line: 268 + column: 145 + keyword: SHOULD + block_type: paragraph + block: 208 + context: …elate to an approved `skillRegister` for the same `(skill_id, digest)`, SHOULD deny when `load_path` shows a skill loading another outside its declare… + binding: + kind: unbound + - source: spec/instrument/hooks.md + line: 268 + column: 248 + keyword: SHOULD + block_type: paragraph + block: 208 + context: …ows a skill loading another outside its declared `composed_skills`, and SHOULD deny when the loaded artifact's digest does not match the one vetted at… + binding: + kind: unbound + - source: spec/instrument/hooks.md + line: 312 + column: 40 + keyword: MAY + block_type: paragraph + block: 230 + context: "**Decision:** Normally ALLOW. Guardian MAY DENY to refuse a session whose component graph contains a banned model,…" + binding: + kind: unbound + - source: spec/instrument/hooks.md + line: 324 + column: 38 + keyword: MAY + block_type: paragraph + block: 236 + context: "**Decision:** ALLOW / DENY. Guardian MAY DENY to block a hot-swap; otherwise audited." + binding: + kind: unbound + - source: spec/instrument/hooks.md + line: 342 + column: 423 + keyword: MAY + block_type: paragraph + block: 245 + context: …s on top. Deployments that only need transport-agnostic tool governance MAY collapse MCP tool calls into `steps/toolCallRequest`; deployments that… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 12 + column: 16 + keyword: MUST NOT + block_type: list_item + block: 7 + context: 2. **The agent MUST NOT have knowledge of hooks.** Provenance fields, when emitted, MUST be pop… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 12 + column: 85 + keyword: MUST + block_type: list_item + block: 7 + context: …nt MUST NOT have knowledge of hooks.** Provenance fields, when emitted, MUST be populated outside the LLM's output path. + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 13 + column: 365 + keyword: OPTIONAL + block_type: list_item + block: 8 + context: …origin` + `source_id` against local policy. The wire format reserves an OPTIONAL `trust` enum for vendor implementations that elect to carry the classif… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 59 + column: 117 + keyword: SHOULD + block_type: paragraph + block: 36 + context: …d in v0.1.0. Batching is permitted as standard JSON-RPC 2.0 — Guardians SHOULD accept array-shaped requests and return an array of correlated response… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 59 + column: 475 + keyword: MUST + block_type: paragraph + block: 36 + context: …if signed) its own signature. A Guardian that does not support batching MUST return `-32600 Invalid Request` for array-shaped inputs so the Observed… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 71 + column: 90 + keyword: MUST + block_type: paragraph + block: 42 + context: "…terminates with `UNSUPPORTED_VERSION` (`-32001`, §17.1). Unknown fields MUST be ignored. If the client declares `provenance_producer: \"none\"` and th…" + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 71 + column: 230 + keyword: MUST + block_type: paragraph + block: 42 + context: …` and the Guardian's `policy_requires_provenance` is true, the Guardian MUST refuse the session at handshake time with `PROVENANCE_REQUIRED` (`-3200… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 77 + column: 204 + keyword: MUST + block_type: paragraph + block: 45 + context: …osture applies only when no answer arrives. A session started unguarded MUST be recorded in the deployment's own audit log — there is no Guardian to… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 77 + column: 305 + keyword: SHOULD + block_type: paragraph + block: 45 + context: …yment's own audit log — there is no Guardian to receive the event — and SHOULD be surfaced on Trace events when the deployment claims ACS-Trace. The O… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 77 + column: 397 + keyword: SHOULD + block_type: paragraph + block: 45 + context: …n Trace events when the deployment claims ACS-Trace. The Observed Agent SHOULD retry the handshake for subsequent sessions. Whether a Guardian can att… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 113 + column: 41 + keyword: RECOMMENDED + block_type: table_cell + block: 116 + context: none (`reasoning` RECOMMENDED when user-visible audit trails are expected) + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 119 + column: 110 + keyword: MUST + block_type: paragraph + block: 129 + context: …, `conflicting_policies`, `low_confidence`, `pending_dependency`. DEFER MUST include `resolution_method`, `resolution_timeout_ms`, and `timeout_deci… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 119 + column: 230 + keyword: MUST + block_type: paragraph + block: 129 + context: …meout_ms`, and `timeout_decision` (default `deny`). Cascading deferrals MUST be bounded per session. + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 130 + column: 183 + keyword: SHOULD + block_type: table_cell + block: 141 + context: …t-internal consumption; deployments wanting different text per audience SHOULD compose them client-side from `reasoning` + `policy_data` + `reason_cod… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 131 + column: 134 + keyword: OPTIONAL + block_type: table_cell + block: 144 + context: …sion, policy_name, rule_id}`, the rules that fired. `policy_version` is OPTIONAL and deployment-defined, but SHOULD be populated when replay or ledger-b… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 131 + column: 171 + keyword: SHOULD + block_type: table_cell + block: 144 + context: …es that fired. `policy_version` is OPTIONAL and deployment-defined, but SHOULD be populated when replay or ledger-backed policy state matters. A singl… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 131 + column: 260 + keyword: MAY + block_type: table_cell + block: 144 + context: …ed when replay or ledger-backed policy state matters. A single decision MAY cite multiple entries when several paradigms reject the same action; au… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 132 + column: 122 + keyword: SHOULD + block_type: table_cell + block: 147 + context: …categorization strings. Free vocabulary in v0.1. UIs and meta-policies SHOULD switch on these rather than parsing reasoning text or rule IDs. + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 140 + column: 689 + keyword: MAY + block_type: paragraph + block: 161 + context: "….g., IBAC outer + FIDES inner across an A2A boundary) a single decision MAY cite all of them: `policy_references` with one entry per paradigm, `rea…" + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 146 + column: 136 + keyword: MUST NOT + block_type: list_item + block: 164 + context: "…tire payload. It is exclusive: a MODIFY that carries `modified_content` MUST NOT also carry `redactions` or `parameter_overrides`, because path-addresse…" + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 147 + column: 64 + keyword: MAY + block_type: list_item + block: 165 + context: "- **Structured edits.** `redactions` and `parameter_overrides` MAY appear together, but their targets MUST be disjoint: no `redactions` pa…" + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 147 + column: 103 + keyword: MUST + block_type: list_item + block: 165 + context: "…tions` and `parameter_overrides` MAY appear together, but their targets MUST be disjoint: no `redactions` path may address the same field as a `para…" + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 149 + column: 12 + keyword: MUST NOT + block_type: paragraph + block: 166 + context: A Guardian MUST NOT emit a `modifications` object that violates either rule. An Observed Ag… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 149 + column: 157 + keyword: MUST + block_type: paragraph + block: 166 + context: …rved Agent that receives one cannot determine the Guardian's intent and MUST fail closed, treating the decision as `DENY`, and SHOULD record an audi… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 149 + column: 212 + keyword: SHOULD + block_type: paragraph + block: 166 + context: …ian's intent and MUST fail closed, treating the decision as `DENY`, and SHOULD record an audit event. + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 157 + column: 138 + keyword: MUST + block_type: paragraph + block: 170 + context: …e verdict and applies it. For every step it submits, the Observed Agent MUST wait for the Guardian's decision, up to the negotiated timeout (`timeou… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 157 + column: 234 + keyword: MUST + block_type: paragraph + block: 170 + context: "…an's decision, up to the negotiated timeout (`timeout_config`, §4), and MUST apply it: `ALLOW` proceeds, `DENY` blocks the action, `MODIFY` proceeds…" + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 159 + column: 538 + keyword: MAY + block_type: paragraph + block: 171 + context: "…rroring, or unreachable Guardian does not halt production. A deployment MAY set `on_decision_failure: deny` (fail-closed). The negotiated timeout b…" + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 159 + column: 708 + keyword: MAY + block_type: paragraph + block: 171 + context: "…e: an error from the §17.1 registry carries a recovery action the agent MAY attempt within the remaining budget, and an unambiguous failure (a refu…" + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 159 + column: 799 + keyword: MAY + block_type: paragraph + block: 171 + context: …the remaining budget, and an unambiguous failure (a refused connection) MAY resolve immediately rather than waiting out the clock. + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 161 + column: 45 + keyword: MUST + block_type: paragraph + block: 172 + context: Every step that proceeds without a decision MUST be recorded as an audit event, so the bypass is visible rather than sil… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 161 + column: 184 + keyword: MUST + block_type: paragraph + block: 172 + context: …than silent. When a decision does arrive within the timeout, the agent MUST honor it regardless of the posture. Fail-open trades enforcement for av… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 169 + column: 43 + keyword: MUST + block_type: list_item + block: 176 + context: "- Under **`deterministic`**, the producer MUST attach a Provenance object to **every** data-bearing field in every hoo…" + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 170 + column: 107 + keyword: MUST + block_type: list_item + block: 177 + context: …mits no Provenance objects. A Guardian whose policy requires provenance MUST refuse such a session at handshake time (§4) rather than accept provena… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 172 + column: 59 + keyword: OPTIONAL + block_type: paragraph + block: 178 + context: The base hook payload schemas therefore mark `provenance` OPTIONAL so that ACS-Core (pure IBAC and other paradigms that need no informatio… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 172 + column: 416 + keyword: MUST + block_type: paragraph + block: 178 + context: "…et. When a Provenance object is emitted, all of its own required fields MUST be populated. Schema: [`provenance.json`](https://github.com/afogel/ACS…" + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 183 + column: 29 + keyword: OPTIONAL + block_type: paragraph + block: 200 + context: The wire format reserves an OPTIONAL `trust` enum (`trusted`, `untrusted`, `unknown`) so vendor Guardian imp… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 187 + column: 31 + keyword: MUST + block_type: list_item + block: 202 + context: "- The framework, not the LLM, MUST attach the label, deterministically, based on which channel data crosse…" + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 188 + column: 58 + keyword: MUST + block_type: list_item + block: 203 + context: "- For data with `origin: agent_generated`, the framework MUST compute `trust` as the minimum trust of the entries in `derived_from` (…" + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 189 + column: 66 + keyword: MUST + block_type: list_item + block: 204 + context: "- Receivers (especially across A2A or multi-Guardian boundaries) MUST treat the field as a hint and re-derive trust against local policy keye…" + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 193 + column: 129 + keyword: MAY + block_type: paragraph + block: 206 + context: …y vendor implementations that populate `trust` on the wire. Deployments MAY override in policy but SHOULD record overrides in audit metadata. + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 193 + column: 156 + keyword: SHOULD + block_type: paragraph + block: 206 + context: …at populate `trust` on the wire. Deployments MAY override in policy but SHOULD record overrides in audit metadata. + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 205 + column: 12 + keyword: MUST + block_type: paragraph + block: 223 + context: Provenance MUST be populated by deterministic code outside the LLM's output path. Imple… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 205 + column: 99 + keyword: MUST NOT + block_type: paragraph + block: 223 + context: …ed by deterministic code outside the LLM's output path. Implementations MUST NOT instruct the LLM to produce it. The agent declares `provenance_producer… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 205 + column: 320 + keyword: MUST + block_type: paragraph + block: 223 + context: …no Provenance objects, and Guardians whose policies require Provenance MUST refuse the session at handshake time rather than silently degrading enf… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 209 + column: 282 + keyword: MAY + block_type: paragraph + block: 225 + context: …lly observable rather than purely Guardian-internal. The Observed Agent MAY also send `session_id` and a `chain_hash` for cross-checking. + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 211 + column: 220 + keyword: MAY + block_type: paragraph + block: 226 + context: …produced by `sessionStart`; deployments that do not emit `sessionStart` MAY allow the Guardian to implicitly initialize the chain at the first cont… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 222 + column: 5 + keyword: SHOULD + block_type: list_item + block: 232 + context: "- **SHOULD:** `request_hash` (lowercase-hex SHA-256 of the JCS-canonicalized reque…" + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 222 + column: 231 + keyword: MUST + block_type: list_item + block: 232 + context: …t to request content, so deployments claiming the **ACS-Audit** profile MUST populate `request_hash`), `timestamp`, `provenance_summary`, `previous_… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 234 + column: 22 + keyword: MUST + block_type: paragraph + block: 239 + context: Conformant Guardians MUST compute `entry_hash` this way; otherwise chains computed by different i… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 238 + column: 297 + keyword: OPTIONAL + block_type: paragraph + block: 241 + context: …nd at the session level (cumulative across the session). All fields are OPTIONAL — Guardians populate only what their policies consume. Available v0.1 f… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 242 + column: 11 + keyword: OPTIONAL + block_type: paragraph + block: 243 + context: Intent is OPTIONAL and is defined in [Concepts › Intent](../../concepts/intent.md) (normat… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 242 + column: 166 + keyword: REQUIRED + block_type: paragraph + block: 243 + context: "…). Wire fields: `raw`, `parsed` (capability list), `parser_provenance` (REQUIRED if `parsed` present; `origin` MUST be `user_input`), `scope_mode`." + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 242 + column: 205 + keyword: MUST + block_type: paragraph + block: 243 + context: …lity list), `parser_provenance` (REQUIRED if `parsed` present; `origin` MUST be `user_input`), `scope_mode`. + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 244 + column: 331 + keyword: MUST + block_type: paragraph + block: 244 + context: "…an approver's `intent_extension` via the ASK flow (§9.1). The framework MUST enforce it: any attempt to modify `Intent.parsed` by the runtime LLM, b…" + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 244 + column: 466 + keyword: MUST + block_type: paragraph + block: 244 + context: …untime LLM, by tool outputs, or by data crossing an `untrusted` channel MUST be ignored or rejected, and SHOULD be recorded as an audit event. This… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 244 + column: 499 + keyword: SHOULD + block_type: paragraph + block: 244 + context: …y data crossing an `untrusted` channel MUST be ignored or rejected, and SHOULD be recorded as an audit event. This rule is load-bearing for IBAC's cen… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 248 + column: 17 + keyword: MAY + block_type: paragraph + block: 246 + context: Guardian Agents MAY archive entries when SessionContext exceeds a configurable byte thresho… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 248 + column: 123 + keyword: MUST + block_type: paragraph + block: 246 + context: …ntext exceeds a configurable byte threshold (suggested 64 KB). Archival MUST preserve `chain_hash`, `provenance_summary`, and `intent`. A mismatched… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 248 + column: 213 + keyword: SHOULD + block_type: paragraph + block: 246 + context: …in_hash`, `provenance_summary`, and `intent`. A mismatched `chain_hash` SHOULD trigger an audit event. + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 254 + column: 99 + keyword: MUST + block_type: paragraph + block: 249 + context: …uardian writes a ContextEntry (the content-bearing steps), the Guardian MUST include the resulting `chain_hash` in its response, and that `chain_has… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 254 + column: 178 + keyword: MUST + block_type: paragraph + block: 249 + context: …clude the resulting `chain_hash` in its response, and that `chain_hash` MUST be covered by the response signature (§10). Publishing the head as each… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 256 + column: 202 + keyword: MAY + block_type: paragraph + block: 250 + context: "…'s `chain_hash` (cross-check) that disagrees with its own computed head MAY DENY with `reason_codes: [\"chain_mismatch\"]`, or return the `CHAIN_MISM…" + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 256 + column: 451 + keyword: SHOULD + block_type: paragraph + block: 250 + context: …t finds a published `chain_hash` inconsistent with the recomputed chain SHOULD treat it as an integrity event, not a transient error. + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 262 + column: 15 + keyword: MAY + block_type: paragraph + block: 253 + context: ASK approvers MAY be human, agent, or service. `ask_details.approver = { type, id, endpoi… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 262 + column: 204 + keyword: REQUIRED + block_type: paragraph + block: 253 + context: …request and returns an ACS-shaped decision. Approver authentication is REQUIRED. Guardian MUST verify approver identity against policy. + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 262 + column: 223 + keyword: MUST + block_type: paragraph + block: 253 + context: …s an ACS-shaped decision. Approver authentication is REQUIRED. Guardian MUST verify approver identity against policy. + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 264 + column: 36 + keyword: MUST NOT + block_type: paragraph + block: 254 + context: Single-hop only in v0.1. Approvers MUST NOT return ASK. Quorum and recursive ASK deferred to v0.2. + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 268 + column: 95 + keyword: MAY + block_type: paragraph + block: 256 + context: …ASK because a request is outside `Intent.parsed`, the approver's grant MAY include an `intent_extension` field (see [`ask-details.json`](https://g… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 270 + column: 35 + keyword: MUST + block_type: paragraph + block: 257 + context: "On `scope: session`, the Guardian MUST:" + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 276 + column: 110 + keyword: MUST NOT + block_type: paragraph + block: 261 + context: "…session's `scope_mode`: a Guardian operating under `scope_mode: strict` MUST NOT honor extensions that would add capabilities the deployment policy forb…" + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 282 + column: 81 + keyword: MUST NOT + block_type: paragraph + block: 264 + context: "…Guardian determines that the client cannot resolve `ASK`, the Guardian MUST NOT return `ASK`. The Guardian MUST instead substitute one of:" + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 282 + column: 117 + keyword: MUST + block_type: paragraph + block: 264 + context: "…cannot resolve `ASK`, the Guardian MUST NOT return `ASK`. The Guardian MUST instead substitute one of:" + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 287 + column: 42 + keyword: SHOULD + block_type: paragraph + block: 267 + context: "The choice is policy-driven: deployments SHOULD prefer `DEFER` when the request is potentially recoverable through a di…" + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 293 + column: 45 + keyword: REQUIRED + block_type: paragraph + block: 270 + context: A signature over the §10 canonical input is REQUIRED in ACS-Core. The signature envelope is `{ algorithm, value, key_id }`,… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 295 + column: 470 + keyword: MUST + block_type: paragraph + block: 271 + context: …g a signed `toolCallResult` under a different `session_id`). A verifier MUST recompute this canonical form and MUST reject a signature that does not… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 295 + column: 509 + keyword: MUST + block_type: paragraph + block: 271 + context: …ferent `session_id`). A verifier MUST recompute this canonical form and MUST reject a signature that does not cover it (`SIGNATURE_INVALID`, §17.1).… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 299 + column: 517 + keyword: RECOMMENDED + block_type: paragraph + block: 273 + context: …egistered and available; a future version is expected to promote PQC to RECOMMENDED once ecosystem support matures. + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 303 + column: 35 + keyword: RECOMMENDED + block_type: table_cell + block: 279 + context: RECOMMENDED. Shared-secret integrity; simplest deployment path. Sufficient for same… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 304 + column: 41 + keyword: OPTIONAL + block_type: table_cell + block: 282 + context: OPTIONAL. Strongest current ecosystem support across Java, Node, .NET, HSMs, and… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 305 + column: 45 + keyword: OPTIONAL + block_type: table_cell + block: 285 + context: OPTIONAL. Legacy interop; deployments with existing RSA PKI. + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 306 + column: 43 + keyword: OPTIONAL + block_type: table_cell + block: 288 + context: OPTIONAL. ~128-bit post-quantum security; ~3.3 KB signatures. Recommended for de… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 307 + column: 32 + keyword: OPTIONAL + block_type: table_cell + block: 291 + context: OPTIONAL. Low-bandwidth profile. + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 308 + column: 32 + keyword: OPTIONAL + block_type: table_cell + block: 294 + context: OPTIONAL. High-security profile. + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 309 + column: 43 + keyword: OPTIONAL + block_type: table_cell + block: 297 + context: "OPTIONAL. Algorithmic diversity vs. ML-DSA's lattice assumption. Caution: ~7.8 K…" + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 310 + column: 32 + keyword: OPTIONAL + block_type: table_cell + block: 300 + context: OPTIONAL. Faster signing; larger signatures. + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 311 + column: 37 + keyword: OPTIONAL + block_type: table_cell + block: 303 + context: OPTIONAL. Transitional composite for PQC forward-resistance with classical co-si… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 312 + column: 41 + keyword: OPTIONAL + block_type: table_cell + block: 306 + context: OPTIONAL. Transitional composite. + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 314 + column: 126 + keyword: RECOMMENDED + block_type: paragraph + block: 307 + context: …PQC-primary. A future ACS version is expected to promote `ML-DSA-65` to RECOMMENDED and eventually deprecate classical-only algorithms, but the timeline de… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 318 + column: 284 + keyword: MUST + block_type: paragraph + block: 309 + context: …nteger and the whole blob is base64-encoded for wire transit. Verifiers MUST verify both component signatures over the canonical input defined in §1… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 322 + column: 92 + keyword: MUST + block_type: paragraph + block: 311 + context: …, `timestamp` (ISO 8601), and optional `nonce` (16–64 bytes). Guardians MUST reject requests whose `timestamp` is more than the negotiated skew wind… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 322 + column: 209 + keyword: RECOMMENDED + block_type: paragraph + block: 311 + context: …e than the negotiated skew window (`skew_window_ms` in ServerHello, §4; RECOMMENDED default 300000) in the past or future, returning `TIMESTAMP_OUT_OF_WIND… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 322 + column: 315 + keyword: MUST + block_type: paragraph + block: 311 + context: …past or future, returning `TIMESTAMP_OUT_OF_WINDOW` (`-32006`, §17.1), MUST reject duplicate `request_id` values within the session with `REPLAY_DE… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 322 + column: 422 + keyword: SHOULD + block_type: paragraph + block: 311 + context: …values within the session with `REPLAY_DETECTED` (`-32005`, §17.1), and SHOULD reject duplicate `nonce` values within a sliding window the deployment… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 326 + column: 5 + keyword: MUST + block_type: paragraph + block: 313 + context: ACS MUST be deployable across IDE, SaaS, on-prem on Linux/Windows/macOS/mobile/b… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 328 + column: 24 + keyword: MUST + block_type: list_item + block: 314 + context: "- Resource identifiers MUST use URI form (`file:///C:/...`, `posix:///etc/...`, `https://...`)." + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 352 + column: 10 + keyword: MUST + block_type: list_item + block: 331 + context: "- Prompt MUST treat untrusted data as data, not instructions. Untrusted fields MUST b…" + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 352 + column: 80 + keyword: MUST + block_type: list_item + block: 331 + context: …t MUST treat untrusted data as data, not instructions. Untrusted fields MUST be wrapped/quoted. + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 353 + column: 3 + keyword: MUST NOT + block_type: list_item + block: 332 + context: "- MUST NOT have access to deterministic-layer policy code." + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 354 + column: 13 + keyword: MUST + block_type: list_item + block: 333 + context: "- Decisions MUST be logged with reasoning, model identifier, confidence (when available)…" + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 357 + column: 1 + keyword: OPTIONAL + block_type: paragraph + block: 335 + context: OPTIONAL for v0.1.0. Deterministic-only deployments are fully conformant. + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 371 + column: 13 + keyword: MUST + block_type: list_item + block: 342 + context: "- Guardians MUST always return `decision: \"allow\"` for `system/ping` regardless of polic…" + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 372 + column: 17 + keyword: MUST NOT + block_type: list_item + block: 343 + context: "- `system/ping` MUST NOT be written into SessionContext as a ContextEntry; it does not participa…" + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 373 + column: 17 + keyword: MUST NOT + block_type: list_item + block: 344 + context: "- `system/ping` MUST NOT require a signature even if the session otherwise requires signatures,…" + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 374 + column: 247 + keyword: SHOULD + block_type: list_item + block: 345 + context: …uring a key-resolution outage (`SIGNATURE_INVALID`, §17.1). Deployments SHOULD monitor hook-path decision failures (§6.4) directly rather than infer e… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 375 + column: 112 + keyword: MAY + block_type: list_item + block: 346 + context: …t for `system/ping` is a transport-level signal that the Observed Agent MAY use to renegotiate transport, re-handshake, or fail over; it MUST NOT b… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 375 + column: 177 + keyword: MUST NOT + block_type: list_item + block: 346 + context: …Agent MAY use to renegotiate transport, re-handshake, or fail over; it MUST NOT be interpreted as an enforcement event. + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 394 + column: 10 + keyword: RECOMMENDED + block_type: table_cell + block: 378 + context: PQC as RECOMMENDED default + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 420 + column: 120 + keyword: MAY + block_type: paragraph + block: 421 + context: …an SDK can branch on the code without parsing prose. An error response MAY carry a `data` object; when present it SHOULD include a machine-readabl… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 420 + column: 163 + keyword: SHOULD + block_type: paragraph + block: 421 + context: …ing prose. An error response MAY carry a `data` object; when present it SHOULD include a machine-readable `reason` and a human-readable `message`, plu… + binding: + kind: unbound + - source: spec/instrument/specification.md + line: 433 + column: 15 + keyword: MUST NOT + block_type: paragraph + block: 458 + context: "`system/ping` MUST NOT return an ACS-specific error, so liveness probing survives signature-ro…" + binding: + kind: unbound + - source: spec/trace/events.md + line: 33 + column: 67 + keyword: MUST + block_type: paragraph + block: 61 + context: When Provenance is attached to a hook payload, the resulting span MUST carry `acs.provenance.origin` as an attribute, and SHOULD carry `acs.pr… + binding: + kind: unbound + - source: spec/trace/events.md + line: 33 + column: 123 + keyword: SHOULD + block_type: paragraph + block: 61 + context: …resulting span MUST carry `acs.provenance.origin` as an attribute, and SHOULD carry `acs.provenance.source_id` and `acs.provenance.lineage_depth` whe… + binding: + kind: unbound + - source: spec/trace/events.md + line: 33 + column: 455 + keyword: MAY + block_type: paragraph + block: 61 + context: …../instrument/specification.md#7-provenance)). Provenance lineage edges MAY be linked via OTel span links keyed by `provenance_id`. + binding: + kind: unbound + - source: spec/trace/events.md + line: 66 + column: 37 + keyword: MUST + block_type: paragraph + block: 112 + context: "A deployment claiming **ACS-Trace** MUST:" + binding: + kind: unbound + - source: spec/trace/events.md + line: 72 + column: 14 + keyword: MUST NOT + block_type: paragraph + block: 116 + context: Trace events MUST NOT block enforcement — failure of the Trace sink MUST NOT change the dispo… + binding: + kind: unbound + - source: spec/trace/events.md + line: 72 + column: 69 + keyword: MUST NOT + block_type: paragraph + block: 116 + context: Trace events MUST NOT block enforcement — failure of the Trace sink MUST NOT change the disposition returned to the Observed Agent. Deployments that… + binding: + kind: unbound + - source: spec/trace/events.md + line: 72 + column: 173 + keyword: SHOULD + block_type: paragraph + block: 116 + context: …returned to the Observed Agent. Deployments that do not claim ACS-Trace SHOULD still emit Trace events where feasible; the vocabulary is normative reg… + binding: + kind: unbound + - source: spec/trace/extend_opentelemetry.md + line: 20 + column: 60 + keyword: MUST + block_type: paragraph + block: 12 + context: When a hook payload carries Provenance, the resulting span MUST carry `acs.provenance.origin` and SHOULD carry `acs.provenance.source_i… + binding: + kind: unbound + - source: spec/trace/extend_opentelemetry.md + line: 20 + column: 99 + keyword: SHOULD + block_type: paragraph + block: 12 + context: …s Provenance, the resulting span MUST carry `acs.provenance.origin` and SHOULD carry `acs.provenance.source_id` and `acs.provenance.lineage_depth`. Th… + binding: + kind: unbound + - source: spec/trace/extend_opentelemetry.md + line: 20 + column: 279 + keyword: MAY + block_type: paragraph + block: 12 + context: …by a Guardian decision (the response envelope's `cited_provenance_ids`) MAY be expressed as OTel span links keyed by `provenance_id`, letting tools… + binding: + kind: unbound + - source: spec/trace/extend_opentelemetry.md + line: 26 + column: 93 + keyword: SHOULD + block_type: paragraph + block: 15 + context: …user prompts, tool arguments, and retrieved knowledge. Implementations SHOULD apply the deployment's redaction or hashing policy at attribute-emit ti… + binding: + kind: unbound + - source: spec/trace/extend_opentelemetry.md + line: 30 + column: 156 + keyword: MAY + block_type: paragraph + block: 17 + context: …P to existing backends. The Guardian's handshake `trace_emission` field MAY advertise an OTLP collector endpoint; when set, the Observed Agent SHOU… + binding: + kind: unbound + - source: spec/trace/extend_opentelemetry.md + line: 30 + column: 227 + keyword: SHOULD + block_type: paragraph + block: 17 + context: …MAY advertise an OTLP collector endpoint; when set, the Observed Agent SHOULD route ACS-shaped trace traffic there in addition to (or instead of) its… + binding: + kind: unbound + - source: spec/trace/extend_opentelemetry.md + line: 34 + column: 14 + keyword: MUST NOT + block_type: paragraph + block: 19 + context: Trace events MUST NOT block enforcement. If the Trace sink is unreachable or returns an error… + binding: + kind: unbound + - source: spec/trace/extend_opentelemetry.md + line: 34 + column: 123 + keyword: MUST + block_type: paragraph + block: 19 + context: …ace sink is unreachable or returns an error, the Guardian's disposition MUST still be returned to the Observed Agent. The agent's hot path is enforc… + binding: + kind: unbound + - source: topics/ACS_in_action_example.md + line: 46 + column: 61 + keyword: OPTIONAL + block_type: paragraph + block: 7 + context: This example shows a deployment that elects to populate the OPTIONAL `trust` field on the wire. An equally-conformant v0.1 deployment would… + binding: + kind: excluded + reason: informative_source + - source: topics/ACS_in_action_example.md + line: 237 + column: 49 + keyword: MAY + block_type: paragraph + block: 33 + context: In multi-paradigm deployments a single decision MAY cite all firing paradigms — `policy_references` carries one entry per p… + binding: + kind: excluded + reason: informative_source diff --git a/ir/census/sources.yaml b/ir/census/sources.yaml new file mode 100644 index 0000000..be4d40d --- /dev/null +++ b/ir/census/sources.yaml @@ -0,0 +1,217 @@ +# The normative source census (S11, authored). R1.1: corpus membership is +# declared here, never inferred from an RFC 2119 grep. Every Markdown file +# under spec/acs/docs/ appears exactly once; `acs-ir census` fails when the +# tree and this list disagree in either direction. +# +# status: +# normative -- participates in conformance. Says what makes it so: +# normative_by: self a pillar specification or a document conformance.md binds +# normative_by: reference force delegated by a citation, listed in referenced_by +# informative -- read by people, ignored by conformance. Keyword occurrences +# here are excluded with reason informative_source. +# editorial -- the spec's policy about its own structure. Not a provision +# source; its keyword occurrences are examples quoted from +# elsewhere and are excluded with reason editorial_source. +# +# Surveyed at ACS v0.1.2 (6fce2a0). Decisions carried from the shaping doc and +# spike X5 (docs/shaping/spike-provision-taxonomy.md): all eight concept pages +# are in; concepts/README.md is the taxonomy's source, not a provision source. + +sources: + # --- landing and topics: read by people, cited by nothing normative --- + - path: README.md + status: informative + note: Site landing page. + - path: about.md + status: informative + note: Project description and contact routes. + - path: acs.md + status: informative + note: One-page overview. Its single OPTIONAL restates conformance.md's trust-enum statement. + - path: topics/core_concepts.md + status: informative + note: Orientation for new readers; every statement here is restated normatively in concepts/ or a pillar. + - path: topics/ACS_in_action_example.md + status: informative + note: Worked example. Its OPTIONAL and MAY describe the example deployment's choices, not obligations. + + # --- concepts: canonical definitions and cross-cutting invariants (X5, D-g) --- + - path: concepts/README.md + status: editorial + pillar: concepts + note: The altitude rule and the migration note. Source of the taxonomy; its three keyword occurrences are quoted examples. + - path: concepts/agents.md + status: normative + normative_by: reference + pillar: concepts + referenced_by: + - concepts/README.md#convention + note: Two (normative) callouts, both Guardian obligations (X5 flags the altitude); restates §9 approver rules (R2.9 pair). + - path: concepts/capability.md + status: normative + normative_by: reference + pillar: concepts + referenced_by: + - concepts/README.md#convention + note: No callouts; carries the canonical definition and a Referenced-by footer. + - path: concepts/identity.md + status: normative + normative_by: reference + pillar: concepts + referenced_by: + - concepts/README.md#convention + note: One (normative) callout that asserts ACS mandates nothing, the type case for the Exclusion node type. + - path: concepts/intent.md + status: normative + normative_by: reference + pillar: concepts + referenced_by: + - spec/instrument/specification.md#84-intent-and-scope-mode + - concepts/README.md#convention + note: Cited "(normative)" from §8.4; two Invariant callouts. + - path: concepts/provenance.md + status: normative + normative_by: reference + pillar: concepts + referenced_by: + - spec/instrument/specification.md#7-provenance + - concepts/README.md#convention + note: Cited "(normative)" from §7; two Invariant callouts and zero RFC 2119 keywords, the reason R1.1 exists. + - path: concepts/session-lifecycle.md + status: normative + normative_by: reference + pillar: concepts + referenced_by: + - concepts/README.md#convention + note: One (normative) callout on subagent intent derivation. + - path: concepts/skill.md + status: normative + normative_by: reference + pillar: concepts + referenced_by: + - concepts/README.md#convention + note: No callouts; canonical definition of a skill as a trust boundary, plus footer. + - path: concepts/trust.md + status: normative + normative_by: reference + pillar: concepts + referenced_by: + - concepts/README.md#convention + note: Two Invariant callouts; "asserted is not attested" is force §7.1 does not restate. + + # --- identity workstream: self-declared non-normative --- + - path: identity/overview.md + status: informative + pillar: concepts + note: '"It does not propose a wire format or normative requirements yet." Its three MUSTs restate §6.4 (new at v0.1.2).' + - path: identity/standards.md + status: informative + pillar: concepts + note: Standards survey. Its RECOMMENDED / REQUIRED quote RFC 6749, not ACS (new at v0.1.2). + + # --- conformance: binds the profiles --- + - path: spec/conformance.md + status: normative + normative_by: self + pillar: conformance + note: Seven profiles and what each obliges. The profile field on every provision (R4.3) resolves here. + + # --- instrument pillar --- + - path: spec/instrument/README.md + status: informative + pillar: instrument + note: Pillar overview and marketing prose; no requirements of its own. + - path: spec/instrument/specification.md + status: normative + normative_by: self + pillar: instrument + note: The core wire specification. 111 of the corpus's keyword occurrences. + - path: spec/instrument/hooks.md + status: normative + normative_by: self + pillar: instrument + note: Hook catalogue with per-hook obligations and the preCompact laundering guard. + - path: spec/instrument/extend_mcp.md + status: normative + normative_by: self + pillar: instrument + note: Wrapped MCP is in ACS-Core (conformance.md) and this page specifies it. + - path: spec/instrument/a2a/extend_a2a.md + status: informative + pillar: instrument + note: hooks.md:35 reserves protocols/A2A/* for v0.2 and defines no normative wrapping semantics in v0.1. + - path: spec/instrument/a2a/hooks/cancel_task_request.md + status: informative + pillar: instrument + note: A2A wrapping, deferred to v0.2 (hooks.md:35). + - path: spec/instrument/a2a/hooks/get_task_push_notification_config_request.md + status: informative + pillar: instrument + note: A2A wrapping, deferred to v0.2 (hooks.md:35). + - path: spec/instrument/a2a/hooks/get_task_request.md + status: informative + pillar: instrument + note: A2A wrapping, deferred to v0.2 (hooks.md:35). + - path: spec/instrument/a2a/hooks/resubscribe_to_task_request.md + status: informative + pillar: instrument + note: A2A wrapping, deferred to v0.2 (hooks.md:35). + - path: spec/instrument/a2a/hooks/send_message_request.md + status: informative + pillar: instrument + note: A2A wrapping, deferred to v0.2 (hooks.md:35). + - path: spec/instrument/a2a/hooks/set_task_push_notification_config_request.md + status: informative + pillar: instrument + note: A2A wrapping, deferred to v0.2 (hooks.md:35). + - path: spec/instrument/a2a/hooks/stream_message_request.md + status: informative + pillar: instrument + note: A2A wrapping, deferred to v0.2 (hooks.md:35). + + # --- trace pillar --- + - path: spec/trace/README.md + status: informative + pillar: trace + note: Pillar overview; the obligations live in events.md and the two extension pages. + - path: spec/trace/events.md + status: normative + normative_by: self + pillar: trace + note: The ACS-Trace vocabulary conformance.md binds a deployment to. + - path: spec/trace/extend_opentelemetry.md + status: normative + normative_by: self + pillar: trace + note: Required attributes for OTel emission. + - path: spec/trace/extend_ocsf.md + status: normative + normative_by: self + pillar: trace + note: OCSF mapping; zero RFC 2119 keywords, its obligations are table-borne (R1.7). + - path: spec/trace/OCSF/implementation_examples.md + status: informative + pillar: trace + note: Implementation examples. + + # --- inspect pillar --- + - path: spec/inspect/README.md + status: normative + normative_by: self + pillar: inspect + note: AgBOM shape and the snapshot/changed hooks conformance.md binds under ACS-Inspect. + - path: spec/inspect/extend_cyclonedx.md + status: normative + normative_by: self + pillar: inspect + note: One of the three normative AgBOM serializations. + - path: spec/inspect/extend_spdx.md + status: normative + normative_by: self + pillar: inspect + note: One of the three normative AgBOM serializations; zero keywords, mapping tables only. + - path: spec/inspect/extend_swid.md + status: normative + normative_by: self + pillar: inspect + note: One of the three normative AgBOM serializations; zero keywords, mapping tables only. diff --git a/ir/package.json b/ir/package.json new file mode 100644 index 0000000..651720f --- /dev/null +++ b/ir/package.json @@ -0,0 +1,19 @@ +{ + "name": "acs-ir", + "private": true, + "type": "module", + "version": "0.1.0", + "description": "ACS Normative IR: the requirement catalog, censuses, and verifier tooling for the pinned ACS spec. Depends on spec/acs and on nothing else in this repository.", + "bin": { + "acs-ir": "src/main.ts" + }, + "scripts": { + "census": "bun run src/main.ts census", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "bun test" + }, + "devDependencies": { + "bun-types": "^1.3.14", + "typescript": "^7.0.2" + } +} diff --git a/ir/slices/v1/README.md b/ir/slices/v1/README.md new file mode 100644 index 0000000..945ab02 --- /dev/null +++ b/ir/slices/v1/README.md @@ -0,0 +1,50 @@ +# V1: The corpus, inventoried + +**Demo:** `bun run ir census` prints two reports. The source census names all eight concept pages and every pillar document with its normative status and what makes it normative. The provision census reports every RFC 2119 keyword occurrence by block type, the ten `(normative)` callouts, and the eight `Referenced by` footers as candidate dependency edges. Nothing is bound yet: 197 occurrences in normative sources are listed as unbound, and 11 in informative or editorial sources are excluded with that reason. + +**Master doc:** [`docs/shaping/normative-ir-slices.md`](../../../docs/shaping/normative-ir-slices.md) §V1, authoritative for this slice's scope. + +**Affordances:** U3, U19, U20, U21, U33, N10, N11, N12, N13, N14, N19, N52, S1, S11. Defined in [Detail E](../../../docs/shaping/normative-ir-shaping.md#detail-e-affordances). + +## What this slice delivers + +The complete inventory of what the later slices must capture, measured against the pinned corpus at ACS v0.1.2 (`6fce2a0`), and reviewable by a spec editor who has never seen the IR (R1.9). + +| Measurement | Value | +|---|---| +| Documents in the corpus | 38 | +| Declared normative / informative / editorial | 19 / 18 / 1 | +| RFC 2119 keyword occurrences | 208 in 134 blocks | +| By block type | paragraph 145, list item 33, table cell 22, blockquote 8; heading, code fence, inline code 0 | +| By keyword | MUST 73, MAY 44, SHOULD 34, MUST NOT 23, OPTIONAL 21, RECOMMENDED 8, REQUIRED 5 | +| Bound / excluded / unbound | 0 / 11 / 197 | +| `(normative)` callouts in `concepts/` | 10, across 6 pages | +| Other `(normative)` tags | 8 headings, 1 lead-in and 2 citations in `specification.md`; 2 mentions in `concepts/README.md` | +| `Referenced by` footers | 8 pages, 22 pillar entries, 0 dangling links, 22 with no provision depending back | + +The shaping survey measured 203 occurrences at `c259f57` (v0.1.0). Pointed at that commit, this scan reproduces the survey exactly: 203 in 132 blocks, paragraph 145, list item 33, table cell 17, blockquote 8. The five new occurrences at v0.1.2 are all in table cells in `identity/overview.md` and `identity/standards.md`, two pages that describe themselves as not yet normative. The source census declares them informative, so they are excluded rather than counted as obligations. + +## The three things the census decides + +**Corpus membership is declared, not grepped (R1.1).** `ir/census/sources.yaml` lists every file under `spec/acs/docs/` with a status and, for normative ones, what makes them normative. `acs-ir census` fails when the tree and the declaration disagree in either direction. That is what makes `concepts/provenance.md`, with zero keywords and two invariants, a normative source, and `identity/overview.md`, with three MUSTs that restate §6.4, not one. + +**Every occurrence is in exactly one state (R1.6).** Bound to a provision ID, excluded with a machine-readable reason, or unbound. In V1 the reasons are `informative_source` and `editorial_source`; V2 adds the bindings and the finer exclusions X3 found necessary, such as `restatement_of`. + +**Node type is not assigned here (E1.4).** The callout scan reports candidates and their tag shape. X5 showed the callouts are a mix of Invariants, Requirements and one Exclusion, so typing is the editor's judgment in V2's authored records. + +## Wires to later slices + +- `by_node_type` is carried per source from V1, all zeros, so the report's shape does not change when V2 starts filling it. +- The dependency-edge audit (U33) reports every footer entry as having no provision depending back. That column fills in V2, when authored `depends_on` edges exist to lint against. +- The migration worklist (U32) and exclusion roster (U34) panels are absent, not empty. They belong to V3 and V6. + +## Names frozen here + +- **Source status** is `normative | informative | editorial`. Editorial is `concepts/README.md` alone: the spec's policy about its own structure, the source of the taxonomy, not a source of provisions. +- **`normative_by`** is `self` (a pillar specification, or a document `conformance.md` binds) or `reference` (force delegated by a citation, listed in `referenced_by`). A citation from an informative page confers nothing. +- **Block types** are the four X3 counted plus the three that must stay at zero: `paragraph`, `list_item`, `table_cell`, `blockquote`, `heading`, `code_fence`, `thematic_break`. +- **Normative tag kinds** are `callout`, `heading`, `lead_in`, `citation`. + +## Not in this slice + +No markers, no IDs, no provisions, no predicates. The unbound count is the burn-down every later slice reduces. diff --git a/ir/src/census/callout-scan.ts b/ir/src/census/callout-scan.ts new file mode 100644 index 0000000..925cdc9 --- /dev/null +++ b/ir/src/census/callout-scan.ts @@ -0,0 +1,58 @@ +/** + * N14: `calloutScan()` -- the `> **Title (normative).**` convention in + * `concepts/`, plus the other places the corpus tags text as normative. + * + * Candidates only. X5 found the callouts are a mix of Invariants, + * Requirements and one Exclusion, so node type is editorial (E1.4): the + * tool marks, the editor types. The census lists what it found and what + * kind of tag it was; nothing here assigns a provision type. + * + * Four tag kinds occur in the corpus: + * - `callout` -- a blockquote opening `**Title (normative).**` (concepts pages) + * - `heading` -- a heading ending in `(normative)` (`### 6.4 Honoring decisions (normative)`) + * - `lead_in` -- a paragraph opening `**Title (normative).**` (§8.4's enforcement paragraph) + * - `citation` -- any other block carrying `(normative)`. In a pillar that is a + * delegation of normative force to a concept page (§7, §8.4), the + * `referenced_by` edges the source census declares; in + * `concepts/README.md` it is the convention describing the tag itself. + */ +import { classifyBlocks, type Block } from "../markdown-blocks.ts"; + +export type NormativeTagKind = "callout" | "heading" | "lead_in" | "citation"; + +export interface NormativeTag { + source: string; + line: number; + kind: NormativeTagKind; + /** The bold title for callouts and lead-ins, the heading text for headings, empty for citations. */ + title: string; + /** Whitespace-collapsed text of the tagged block, without the leading `>` or `#` markers. */ + text: string; +} + +const TAGGED_TITLE = /^\s*\*\*(.+?)\s*\(normative\)\.?\*\*/; +const TAG = /\(normative\)/i; + +export function calloutScan(source: string, text: string, blocks: Block[] = classifyBlocks(text)): NormativeTag[] { + const tags: NormativeTag[] = []; + for (const block of blocks) { + if (block.type === "code_fence" || block.type === "thematic_break") continue; + if (!TAG.test(block.text)) continue; + const body = block.type === "blockquote" ? block.text.replace(/^\s{0,3}>\s?/gm, "") : block.text; + const title = TAGGED_TITLE.exec(body)?.[1]; + if (block.type === "blockquote" && title !== undefined) { + tags.push({ source, line: block.line, kind: "callout", title, text: collapse(body) }); + } else if (block.type === "heading") { + tags.push({ source, line: block.line, kind: "heading", title: collapse(body.replace(/^\s{0,3}#+\s*/, "")), text: collapse(body) }); + } else if (title !== undefined) { + tags.push({ source, line: block.line, kind: "lead_in", title, text: collapse(body) }); + } else { + tags.push({ source, line: block.line, kind: "citation", title: "", text: collapse(body) }); + } + } + return tags; +} + +function collapse(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} diff --git a/ir/src/census/keyword-scan.ts b/ir/src/census/keyword-scan.ts new file mode 100644 index 0000000..b6ef516 --- /dev/null +++ b/ir/src/census/keyword-scan.ts @@ -0,0 +1,100 @@ +/** + * N13: `keywordScan()` -- the RFC 2119 sweep, block-type aware. + * + * Reports every keyword occurrence with its file, line, column, block type + * and a one-line context window. Keywords inside inline code spans and + * HTML comments are masked out before matching and counted separately, so + * a future `` terminator or a quoted `MUST` in a code + * span can never inflate the count. RFC 8174 scope: uppercase forms only. + * + * The alternation lists two-word forms before their one-word prefixes so + * `MUST NOT` is never counted as `MUST`. + */ +import { classifyBlocks, type Block, type BlockType } from "../markdown-blocks.ts"; + +export const RFC2119_KEYWORDS = [ + "MUST NOT", + "MUST", + "SHALL NOT", + "SHALL", + "SHOULD NOT", + "SHOULD", + "NOT RECOMMENDED", + "RECOMMENDED", + "MAY", + "OPTIONAL", + "REQUIRED", +] as const; + +export type Keyword = (typeof RFC2119_KEYWORDS)[number]; + +export interface Occurrence { + source: string; + line: number; + /** 1-based character offset within the line. */ + column: number; + keyword: Keyword; + block_type: BlockType; + /** 1-based ordinal of the block within the file, so two occurrences in one block are visibly one block. */ + block: number; + /** Whitespace-collapsed window around the keyword, for an editor reading the table without opening the file. */ + context: string; +} + +export interface KeywordScan { + source: string; + occurrences: Occurrence[]; + /** Occurrences found only inside inline code spans or HTML comments -- mentions, not uses. Expected empty. */ + masked: Occurrence[]; + blocks_with_keywords: number; +} + +const KEYWORD = new RegExp(`\\b(${RFC2119_KEYWORDS.join("|")})\\b`, "g"); +const CONTEXT_RADIUS = 72; + +export function keywordScan(source: string, text: string, blocks: Block[] = classifyBlocks(text)): KeywordScan { + const occurrences: Occurrence[] = []; + const masked: Occurrence[] = []; + let blocksWithKeywords = 0; + + blocks.forEach((block, index) => { + if (block.type === "code_fence" || block.type === "thematic_break") return; + const visible = maskSpans(block.text); + const before = occurrences.length; + for (const match of block.text.matchAll(KEYWORD)) { + const at = match.index ?? 0; + const keyword = match[1] as Keyword; + const occurrence = locate(source, block, index + 1, keyword, at); + (visible.slice(at, at + keyword.length) === keyword ? occurrences : masked).push(occurrence); + } + if (occurrences.length > before) blocksWithKeywords++; + }); + + return { source, occurrences, masked, blocks_with_keywords: blocksWithKeywords }; +} + +function locate(source: string, block: Block, ordinal: number, keyword: Keyword, at: number): Occurrence { + const head = block.text.slice(0, at); + const newlines = head.split("\n").length - 1; + const line = block.line + newlines; + const column = at - (newlines === 0 ? -1 : head.lastIndexOf("\n")) + (block.offset ?? 0); + return { source, line, column, keyword, block_type: block.type, block: ordinal, context: context(block.text, at, keyword) }; +} + +function context(text: string, at: number, keyword: Keyword): string { + const start = Math.max(0, at - CONTEXT_RADIUS); + const end = Math.min(text.length, at + keyword.length + CONTEXT_RADIUS); + const window = text.slice(start, end).replace(/\s+/g, " ").trim(); + return `${start > 0 ? "…" : ""}${window}${end < text.length ? "…" : ""}`; +} + +/** Replace inline code spans and HTML comments with spaces of equal length, preserving every offset. */ +export function maskSpans(text: string): string { + return text + .replace(//g, blank) + .replace(/(`+)[\s\S]*?\1/g, blank); +} + +function blank(match: string): string { + return match.replace(/[^\n]/g, " "); +} diff --git a/ir/src/census/provision-census.ts b/ir/src/census/provision-census.ts new file mode 100644 index 0000000..33963e3 --- /dev/null +++ b/ir/src/census/provision-census.ts @@ -0,0 +1,190 @@ +/** + * N12: `provisionCensus()` -- per source, counts by node type, plus every + * keyword occurrence and what became of it. + * + * R1.3 and R1.6: every RFC 2119 occurrence in the corpus ends up in exactly + * one of three states -- bound to a provision ID, excluded with a + * machine-readable reason, or unbound. Nothing is dropped. In V1 nothing is + * bound yet, so the census is the complete burn-down list: an occurrence in + * a normative source is `unbound`, and one in a source the census declares + * informative or editorial is `excluded` with that source status as the + * reason. Later slices add the finer exclusions X3 found necessary + * (`restatement_of`) and the bindings the overlay creates. + * + * `by_node_type` is carried per source from V1 so the shape of the report + * does not change when V2 starts filling it. + */ +import type { Corpus } from "../corpus.ts"; +import type { BlockType } from "../markdown-blocks.ts"; +import { calloutScan, type NormativeTag } from "./callout-scan.ts"; +import { keywordScan, RFC2119_KEYWORDS, type Keyword, type Occurrence } from "./keyword-scan.ts"; +import { seedDependsOn, type FooterEdge } from "./seed-depends-on.ts"; +import type { SourceDeclaration, SourceStatus } from "./source-census.ts"; + +export type ExclusionReason = "informative_source" | "editorial_source"; + +export type Binding = + | { kind: "unbound" } + | { kind: "excluded"; reason: ExclusionReason } + | { kind: "bound"; provision_id: string }; + +export interface OccurrenceRow extends Occurrence { + binding: Binding; +} + +export interface NodeTypeCounts { + requirement: number; + definition: number; + invariant: number; + exclusion: number; +} + +export interface PerSourceCensus { + path: string; + status: SourceStatus; + occurrences: number; + by_block_type: Record; + by_node_type: NodeTypeCounts; + bound: number; + excluded: number; + unbound: number; + normative_tags: number; + footer_entries: number; +} + +export interface DependencyAudit { + /** Pillar entries in `Referenced by` footers with no provision depending back. All of them, until V2. */ + entries_without_depending_provision: number; + /** Concept pages declared normative that carry no `Referenced by` footer. */ + pages_without_footer: string[]; + /** Footer links whose target document is not in the corpus. */ + dangling_targets: { from: string; line: number; doc: string }[]; +} + +export interface ProvisionCensus { + corpus: { version: string | null; commit: string | null; documents: number }; + totals: { + occurrences: number; + by_keyword: Record; + by_block_type: Record; + blocks_with_keywords: number; + masked: number; + bound: number; + excluded: number; + unbound: number; + }; + sources: PerSourceCensus[]; + normative_tags: NormativeTag[]; + dependency_edges: FooterEdge[]; + dependency_audit: DependencyAudit; + occurrences: OccurrenceRow[]; +} + +const BLOCK_TYPES: BlockType[] = ["paragraph", "list_item", "table_cell", "blockquote", "heading", "code_fence", "thematic_break"]; + +export function provisionCensus( + corpus: Corpus, + declared: SourceDeclaration[], + read: (file: string) => string, +): ProvisionCensus { + const byPath = new Map(declared.map((d) => [d.path, d])); + const exists = (doc: string): boolean => corpus.files.includes(doc); + + const sources: PerSourceCensus[] = []; + const tags: NormativeTag[] = []; + const edges: FooterEdge[] = []; + const rows: OccurrenceRow[] = []; + const pagesWithoutFooter: string[] = []; + let blocksWithKeywords = 0; + let masked = 0; + + for (const file of corpus.files) { + const declaration = byPath.get(file); + if (!declaration) continue; // sourceCensus() already reported it; the census as a whole fails. + const text = read(file); + const scan = keywordScan(file, text); + const fileTags = calloutScan(file, text); + const footer = seedDependsOn(file, text, exists); + + blocksWithKeywords += scan.blocks_with_keywords; + masked += scan.masked.length; + tags.push(...fileTags); + edges.push(...footer.edges); + if (declaration.pillar === "concepts" && declaration.status === "normative" && !footer.has_footer) { + pagesWithoutFooter.push(file); + } + + const perSource: PerSourceCensus = { + path: file, + status: declaration.status, + occurrences: scan.occurrences.length, + by_block_type: zeroBlockTypes(), + by_node_type: { requirement: 0, definition: 0, invariant: 0, exclusion: 0 }, + bound: 0, + excluded: 0, + unbound: 0, + normative_tags: fileTags.length, + footer_entries: footer.edges.length, + }; + for (const occurrence of scan.occurrences) { + const binding = bindingFor(declaration); + perSource.by_block_type[occurrence.block_type]++; + if (binding.kind === "excluded") perSource.excluded++; + else if (binding.kind === "bound") perSource.bound++; + else perSource.unbound++; + rows.push({ ...occurrence, binding }); + } + sources.push(perSource); + } + + const totals = { + occurrences: rows.length, + by_keyword: zeroKeywords(), + by_block_type: zeroBlockTypes(), + blocks_with_keywords: blocksWithKeywords, + masked, + bound: 0, + excluded: 0, + unbound: 0, + }; + for (const row of rows) { + totals.by_keyword[row.keyword]++; + totals.by_block_type[row.block_type]++; + if (row.binding.kind === "bound") totals.bound++; + else if (row.binding.kind === "excluded") totals.excluded++; + else totals.unbound++; + } + + const pillarEdges = edges.filter((e) => e.kind === "pillar"); + const dependency_audit: DependencyAudit = { + entries_without_depending_provision: pillarEdges.length, + pages_without_footer: pagesWithoutFooter, + dangling_targets: edges.flatMap((e) => + e.targets.filter((t) => !t.external && !t.exists).map((t) => ({ from: e.from, line: e.line, doc: t.doc })), + ), + }; + + return { + corpus: { version: corpus.version, commit: corpus.commit, documents: corpus.files.length }, + totals, + sources, + normative_tags: tags, + dependency_edges: edges, + dependency_audit, + occurrences: rows, + }; +} + +function bindingFor(declaration: SourceDeclaration): Binding { + if (declaration.status === "informative") return { kind: "excluded", reason: "informative_source" }; + if (declaration.status === "editorial") return { kind: "excluded", reason: "editorial_source" }; + return { kind: "unbound" }; +} + +function zeroBlockTypes(): Record { + return Object.fromEntries(BLOCK_TYPES.map((t) => [t, 0])) as Record; +} + +function zeroKeywords(): Record { + return Object.fromEntries(RFC2119_KEYWORDS.map((k) => [k, 0])) as Record; +} diff --git a/ir/src/census/run-census.ts b/ir/src/census/run-census.ts new file mode 100644 index 0000000..0e2df94 --- /dev/null +++ b/ir/src/census/run-census.ts @@ -0,0 +1,51 @@ +/** + * N10: `runCensus()` -- source census, provision census, S11. + * + * `ir/census/sources.yaml` is authored and read here; `ir/census/provisions.yaml` + * is generated and written here, and nowhere else. A generated file that + * disagrees with a fresh run is a CI failure (`--check`), for the same + * reason E4.2 gives for the manifest: the committed copy exists so a + * reviewer can read the diff, and a stale copy is a diff nobody read. + */ +import { readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { loadCorpus, readSource, type Corpus } from "../corpus.ts"; +import { toYaml, type YamlValue } from "../yaml.ts"; +import { provisionCensus, type ProvisionCensus } from "./provision-census.ts"; +import { parseSourceDeclarations, sourceCensus, type SourceCensus } from "./source-census.ts"; + +export interface CensusRun { + corpus: Corpus; + sources: SourceCensus; + /** Absent when the source census has problems: a provision census over an undeclared corpus would be a count nobody vouched for. */ + provisions: ProvisionCensus | null; + /** The generated file's content, as it would be written. */ + yaml: string | null; +} + +export function defaultCensusDir(): string { + return resolve(import.meta.dir, "..", "..", "census"); +} + +export function runCensus(options: { corpusRoot?: string; sourcesFile?: string } = {}): CensusRun { + const corpus = loadCorpus(options.corpusRoot); + const sourcesFile = options.sourcesFile ?? join(defaultCensusDir(), "sources.yaml"); + const declared = parseSourceDeclarations(readFileSync(sourcesFile, "utf8")); + const sources = sourceCensus(declared, corpus.files); + if (sources.problems.length > 0) return { corpus, sources, provisions: null, yaml: null }; + + const provisions = provisionCensus(corpus, declared, (file) => readSource(corpus, file)); + return { corpus, sources, provisions, yaml: renderYaml(provisions) }; +} + +const HEADER = [ + "# GENERATED by `acs-ir census` -- do not edit.", + "# The provision census (S11): every RFC 2119 keyword occurrence in the declared", + "# corpus and what became of it. Regenerate with `bun run ir census`; CI runs", + "# `bun run ir census --check` and fails when this file is stale.", + "", +].join("\n"); + +export function renderYaml(census: ProvisionCensus): string { + return HEADER + toYaml(census as unknown as YamlValue); +} diff --git a/ir/src/census/seed-depends-on.ts b/ir/src/census/seed-depends-on.ts new file mode 100644 index 0000000..4d2fd8c --- /dev/null +++ b/ir/src/census/seed-depends-on.ts @@ -0,0 +1,90 @@ +/** + * N19: `seedDependsOn()` -- parse a concept page's `**Referenced by**` + * footer into candidate dependency edges. + * + * X5 found eight concept pages already carry the `depends_on` graph in + * prose, in the reverse direction: the concept names the pillar sections + * that consume it. The IR seeds R3.7's edges from these footers and, once + * provisions exist (V2), lints the two against each other in both + * directions (U33): a footer entry with no provision depending back is a + * missing edge or a stale footer. In V1 every entry is unmatched, and the + * audit says so rather than hiding the column. + * + * Only the footer is read. Body prose links are the page's own + * cross-references, not dependency claims. + */ +import { posix } from "node:path"; + +export interface FooterTarget { + /** Target document, relative to `docs/`; external URLs are kept verbatim. */ + doc: string; + anchor: string | null; + label: string; + external: boolean; + exists: boolean; +} + +export interface FooterEdge { + /** The concept page carrying the footer, relative to `docs/`. */ + from: string; + line: number; + /** `pillar`: an entry naming a pillar that consumes the concept. `see_also`: concept-to-concept, not a dependency claim. */ + kind: "pillar" | "see_also"; + pillar: string | null; + targets: FooterTarget[]; + /** The list item's text, whitespace-collapsed. */ + text: string; +} + +export interface FooterScan { + source: string; + has_footer: boolean; + edges: FooterEdge[]; +} + +const FOOTER = /^\*\*Referenced by\*\*\s*$/; +const ITEM = /^\s*[-*+]\s+(.*)$/; +const PILLAR = /^\*\*([^*]+)\*\*/; +const LINK = /\[([^\]]+)\]\(([^)\s]+)\)/g; + +export function seedDependsOn(source: string, text: string, exists: (doc: string) => boolean): FooterScan { + const lines = text.split(/\r?\n/); + const start = lines.findIndex((l) => FOOTER.test(l)); + if (start === -1) return { source, has_footer: false, edges: [] }; + + const edges: FooterEdge[] = []; + for (let i = start + 1; i < lines.length; i++) { + const raw = lines[i] ?? ""; + if (raw.trim() === "") continue; + const item = ITEM.exec(raw); + if (!item) break; + const body = item[1] ?? ""; + const seeAlso = /^see also\b/i.test(body); + const pillar = seeAlso ? null : (PILLAR.exec(body)?.[1]?.trim() ?? null); + edges.push({ + from: source, + line: i + 1, + kind: seeAlso ? "see_also" : "pillar", + pillar, + targets: parseTargets(source, body, exists), + text: body.replace(/\s+/g, " ").trim(), + }); + } + return { source, has_footer: true, edges }; +} + +function parseTargets(source: string, body: string, exists: (doc: string) => boolean): FooterTarget[] { + const targets: FooterTarget[] = []; + for (const match of body.matchAll(LINK)) { + const label = match[1] ?? ""; + const href = match[2] ?? ""; + if (/^[a-z]+:/i.test(href)) { + targets.push({ doc: href, anchor: null, label, external: true, exists: true }); + continue; + } + const [path = "", anchor] = href.split("#", 2); + const doc = path === "" ? source : posix.normalize(posix.join(posix.dirname(source), path)); + targets.push({ doc, anchor: anchor ?? null, label, external: false, exists: exists(doc) }); + } + return targets; +} diff --git a/ir/src/census/source-census.ts b/ir/src/census/source-census.ts new file mode 100644 index 0000000..7e6979e --- /dev/null +++ b/ir/src/census/source-census.ts @@ -0,0 +1,116 @@ +/** + * N11: `sourceCensus()` -- the declared corpus, checked against the tree. + * + * R1.1: corpus membership is determined by declared normative status, not + * by RFC 2119 grep. `ir/census/sources.yaml` is authored: every document + * under `docs/` appears exactly once with a status, and a normative one + * says what makes it normative -- `self` for a pillar specification, + * `reference` plus `referenced_by` for a page whose force comes from + * elsewhere citing it: a pillar's "(normative)" citation (§7 cites + * `provenance.md`, §8.4 cites `intent.md`) or the spec's own editorial + * policy (`concepts/README.md` declares every concept page canonical). + * A citation from an informative page confers nothing. This module never infers a status; it only checks that the + * declaration and the tree agree, and every disagreement is a problem + * that fails the census, because a document nobody classified is exactly + * the silent omission R1.6 forbids. + */ + +export type SourceStatus = "normative" | "informative" | "editorial"; +export type NormativeBy = "self" | "reference"; + +export interface SourceDeclaration { + /** Path relative to `docs/`. */ + path: string; + status: SourceStatus; + /** Required when `status` is `normative`. */ + normative_by?: NormativeBy; + /** Required when `normative_by` is `reference`: `doc.md#anchor` citations that delegate normative force here. */ + referenced_by?: string[]; + /** Which pillar (or `concepts`) the document belongs to; informational. */ + pillar?: string; + /** Why the editor classified it this way, in one line. */ + note?: string; +} + +export interface SourceCensus { + sources: SourceDeclaration[]; + /** Every way the declaration and the corpus disagree. Empty means the corpus is fully declared. */ + problems: string[]; +} + +const STATUSES: ReadonlySet = new Set(["normative", "informative", "editorial"]); +const NORMATIVE_BY: ReadonlySet = new Set(["self", "reference"]); + +/** Parse the authored declaration file. Throws on a malformed record: the file is authored, so a shape error is an editing error. */ +export function parseSourceDeclarations(yamlText: string): SourceDeclaration[] { + const parsed: unknown = Bun.YAML.parse(yamlText); + if (!isRecord(parsed) || !Array.isArray(parsed.sources)) { + throw new Error("sources.yaml: expected a top-level `sources:` list"); + } + return parsed.sources.map((entry, index) => { + if (!isRecord(entry) || typeof entry.path !== "string" || typeof entry.status !== "string") { + throw new Error(`sources.yaml: entry ${index + 1} needs string \`path\` and \`status\``); + } + if (!STATUSES.has(entry.status)) { + throw new Error(`sources.yaml: ${entry.path}: status must be one of normative | informative | editorial, got ${entry.status}`); + } + const declaration: SourceDeclaration = { path: entry.path, status: entry.status as SourceStatus }; + if (entry.normative_by !== undefined) { + if (typeof entry.normative_by !== "string" || !NORMATIVE_BY.has(entry.normative_by)) { + throw new Error(`sources.yaml: ${entry.path}: normative_by must be self | reference`); + } + declaration.normative_by = entry.normative_by as NormativeBy; + } + if (entry.referenced_by !== undefined) { + if (!Array.isArray(entry.referenced_by) || !entry.referenced_by.every((r) => typeof r === "string")) { + throw new Error(`sources.yaml: ${entry.path}: referenced_by must be a list of strings`); + } + declaration.referenced_by = entry.referenced_by as string[]; + } + if (typeof entry.pillar === "string") declaration.pillar = entry.pillar; + if (typeof entry.note === "string") declaration.note = entry.note; + return declaration; + }); +} + +export function sourceCensus(declared: SourceDeclaration[], corpusFiles: string[]): SourceCensus { + const problems: string[] = []; + const files = new Set(corpusFiles); + const seen = new Set(); + + for (const source of declared) { + if (seen.has(source.path)) problems.push(`${source.path}: declared more than once`); + seen.add(source.path); + if (!files.has(source.path)) problems.push(`${source.path}: declared but not in the corpus`); + + if (source.status === "normative") { + if (!source.normative_by) { + problems.push(`${source.path}: normative sources must say what makes them normative (normative_by: self | reference)`); + } else if (source.normative_by === "reference") { + if (!source.referenced_by?.length) { + problems.push(`${source.path}: normative_by: reference needs a non-empty referenced_by`); + } + for (const ref of source.referenced_by ?? []) { + const doc = ref.split("#", 1)[0] ?? ""; + if (!files.has(doc)) problems.push(`${source.path}: referenced_by cites ${doc}, which is not in the corpus`); + const citing = declared.find((d) => d.path === doc); + if (citing && citing.status === "informative") { + problems.push(`${source.path}: referenced_by cites ${doc}, which is declared informative; a citation carries normative force only from a normative or editorial source`); + } + } + } + } else if (source.normative_by || source.referenced_by) { + problems.push(`${source.path}: only normative sources carry normative_by / referenced_by`); + } + } + + for (const file of corpusFiles) { + if (!seen.has(file)) problems.push(`${file}: in the corpus but not declared in sources.yaml`); + } + + return { sources: declared, problems }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/ir/src/corpus.ts b/ir/src/corpus.ts new file mode 100644 index 0000000..2d30268 --- /dev/null +++ b/ir/src/corpus.ts @@ -0,0 +1,66 @@ +/** + * S1: the pinned normative corpus, `spec/acs/docs/**.md`. + * + * Everything in `ir/` reads the spec through this one module, and this + * module reads the git submodule and nothing else in the repository (R8.2): + * no AGT bridge, no host adapter, no `mapping.yaml`. The default root is + * resolved relative to this file, not the process cwd, so `acs-ir` gives + * the same answer from any directory; `--corpus ` overrides it for a + * second checkout (the reference-number tests point it at the commit the + * shaping survey measured). + */ +import { existsSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +export interface Corpus { + /** Absolute path of the spec checkout (the submodule root). */ + root: string; + /** Absolute path of `docs/`, the directory every source path is relative to. */ + docsDir: string; + /** `git rev-parse HEAD` of the checkout, or null when it is not a git tree. */ + commit: string | null; + /** Contents of `version.txt`, trimmed, or null when absent. */ + version: string | null; + /** Every `*.md` under `docs/`, relative to `docs/`, POSIX separators, sorted. */ + files: string[]; +} + +export function defaultCorpusRoot(): string { + return resolve(import.meta.dir, "..", "..", "spec", "acs"); +} + +export function loadCorpus(root: string = defaultCorpusRoot()): Corpus { + const absRoot = resolve(root); + const docsDir = join(absRoot, "docs"); + if (!existsSync(docsDir)) { + throw new Error( + `corpus not found: ${docsDir} does not exist. ` + + `If this is the pinned submodule, run \`git submodule update --init spec/acs\`.`, + ); + } + const files = Array.from(new Bun.Glob("**/*.md").scanSync({ cwd: docsDir, onlyFiles: true })) + .map((f) => f.split("\\").join("/")) + .sort(); + return { root: absRoot, docsDir, commit: readCommit(absRoot), version: readVersion(absRoot), files }; +} + +export function readSource(corpus: Corpus, file: string): string { + return readFileSync(join(corpus.docsDir, file), "utf8"); +} + +function readVersion(root: string): string | null { + const path = join(root, "version.txt"); + return existsSync(path) ? readFileSync(path, "utf8").trim() : null; +} + +function readCommit(root: string): string | null { + // Only a checkout whose own top level is `root` has a commit to report. A + // fixture directory nested inside another repository would otherwise be + // labelled with that repository's HEAD. + const top = Bun.spawnSync(["git", "-C", root, "rev-parse", "--show-toplevel"], { stdout: "pipe", stderr: "pipe" }); + if (top.exitCode !== 0 || resolve(top.stdout.toString().trim()) !== root) return null; + const result = Bun.spawnSync(["git", "-C", root, "rev-parse", "HEAD"], { stdout: "pipe", stderr: "pipe" }); + if (result.exitCode !== 0) return null; + const sha = result.stdout.toString().trim(); + return /^[0-9a-f]{40}$/.test(sha) ? sha : null; +} diff --git a/ir/src/main.ts b/ir/src/main.ts new file mode 100644 index 0000000..d2ec98a --- /dev/null +++ b/ir/src/main.ts @@ -0,0 +1,71 @@ +#!/usr/bin/env bun +/** + * `acs-ir` -- the IR's command line (P1). + * + * acs-ir census [--check] [--corpus ] [--sources ] [--out ] [--quiet] + * + * `census` writes `ir/census/provisions.yaml` and prints the report. + * `--check` writes nothing and exits 1 if the committed file is stale, so + * CI can refuse a corpus change that nobody regenerated the census for. + * Exit 1 also when the source census has problems: an undeclared document + * is a census failure, not a warning (R1.1, R1.6). + * + * Later slices add `markers apply`, `extract`, `lint`, `compile`, `verify` + * and `render` beside this one command. + */ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { defaultCensusDir, runCensus } from "./census/run-census.ts"; +import { renderCensus } from "./render/census.ts"; + +const USAGE = `usage: acs-ir census [--check] [--corpus ] [--sources ] [--out ] [--quiet]`; + +function flagValue(argv: string[], flag: string): string | undefined { + const index = argv.indexOf(flag); + return index === -1 ? undefined : argv[index + 1]; +} + +export function main(argv: string[]): number { + const [command, ...rest] = argv; + if (command !== "census") { + console.error(USAGE); + return 2; + } + const check = rest.includes("--check"); + const quiet = rest.includes("--quiet"); + const out = flagValue(rest, "--out") ?? join(defaultCensusDir(), "provisions.yaml"); + + const run = runCensus({ corpusRoot: flagValue(rest, "--corpus"), sourcesFile: flagValue(rest, "--sources") }); + if (!quiet || run.sources.problems.length > 0) process.stdout.write(renderCensus(run.sources, run.provisions)); + if (run.yaml === null) { + console.error(`acs-ir census: ${run.sources.problems.length} problem(s) in the source census; nothing written.`); + return 1; + } + + if (check) { + const current = existsSync(out) ? readFileSync(out, "utf8") : null; + if (current === run.yaml) { + console.error(`acs-ir census --check: ${out} is up to date.`); + return 0; + } + console.error( + current === null + ? `acs-ir census --check: ${out} does not exist. Run \`bun run ir census\` and commit the result.` + : `acs-ir census --check: ${out} is stale (first difference at line ${firstDifference(current, run.yaml)}). Run \`bun run ir census\` and commit the result.`, + ); + return 1; + } + + writeFileSync(out, run.yaml); + console.error(`acs-ir census: wrote ${out}`); + return 0; +} + +function firstDifference(a: string, b: string): number { + const as = a.split("\n"); + const bs = b.split("\n"); + for (let i = 0; i < Math.max(as.length, bs.length); i++) if (as[i] !== bs[i]) return i + 1; + return 0; +} + +if (import.meta.main) process.exit(main(process.argv.slice(2))); diff --git a/ir/src/markdown-blocks.ts b/ir/src/markdown-blocks.ts new file mode 100644 index 0000000..c26195a --- /dev/null +++ b/ir/src/markdown-blocks.ts @@ -0,0 +1,183 @@ +/** + * Block classification for the corpus's Markdown. + * + * X3 measured where RFC 2119 keywords live: paragraphs, list items, table + * cells and blockquotes, and nowhere else -- zero in headings, code fences + * or inline code spans. The census has to reproduce that measurement + * (`normative-ir-slices.md` §V1: "If V1's scan disagrees with those, V1's + * scan is wrong"), so this classifier names those four block types and + * also keeps the ones that must come out empty, so that an occurrence + * turning up in a heading is reported as such rather than mis-filed. + * + * This is a line-level classifier, not a CommonMark parser. It knows the + * constructs the corpus uses -- fences, ATX headings, blockquotes, pipe + * tables, list items with lazy continuation, thematic breaks -- and treats + * everything else as paragraph text. Indented code blocks are deliberately + * not recognised: the corpus writes code in fences, and MkDocs admonitions + * (`!!! info` plus indented prose) would otherwise be misread as code. + */ + +export type BlockType = + | "paragraph" + | "list_item" + | "table_cell" + | "blockquote" + | "heading" + | "code_fence" + | "thematic_break"; + +export interface Block { + type: BlockType; + /** 1-based line the block starts on. */ + line: number; + /** 1-based line the block ends on (inclusive). */ + endLine: number; + /** Raw text of the block: the source lines joined by "\n", or the trimmed cell text for a table cell. */ + text: string; + /** Table cells only: 0-based column index within the row. */ + column?: number; + /** Table cells only: 0-based character offset of the trimmed cell text within the source line. */ + offset?: number; +} + +const FENCE_OPEN = /^\s{0,3}(`{3,}|~{3,})/; +const HEADING = /^\s{0,3}#{1,6}(\s|$)/; +const BLOCKQUOTE = /^\s{0,3}>/; +const TABLE_ROW = /^\s*\|/; +const TABLE_SEPARATOR = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/; +const LIST_ITEM = /^\s*([-*+]|\d{1,9}[.)])\s+\S/; +const THEMATIC_BREAK = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/; +const SETEXT_UNDERLINE = /^\s{0,3}(=+|-+)\s*$/; + +export function classifyBlocks(text: string): Block[] { + const lines = text.split(/\r?\n/); + const blocks: Block[] = []; + + let open: { type: "paragraph" | "list_item" | "blockquote"; line: number; lines: string[] } | null = null; + const close = (endLine: number): void => { + if (open) blocks.push({ type: open.type, line: open.line, endLine, text: open.lines.join("\n") }); + open = null; + }; + + let i = 0; + // Front matter: a `---` on line 1 opens a YAML block that runs to the next `---`. + if (lines[0]?.trim() === "---") { + let j = 1; + while (j < lines.length && lines[j]?.trim() !== "---") j++; + i = Math.min(j + 1, lines.length); + } + + for (; i < lines.length; i++) { + const raw = lines[i] ?? ""; + const lineNo = i + 1; + + const fence = FENCE_OPEN.exec(raw); + if (fence) { + close(lineNo - 1); + const marker = fence[1] ?? ""; + const char = marker[0] ?? "`"; + const start = lineNo; + const body: string[] = [raw]; + let j = i + 1; + for (; j < lines.length; j++) { + const l = lines[j] ?? ""; + body.push(l); + const closeRun = new RegExp(`^\\s{0,3}${char === "`" ? "`" : "~"}{${marker.length},}\\s*$`); + if (closeRun.test(l)) break; + } + blocks.push({ type: "code_fence", line: start, endLine: Math.min(j + 1, lines.length), text: body.join("\n") }); + i = j; + continue; + } + + if (raw.trim() === "") { + close(lineNo - 1); + continue; + } + + if (open?.type === "paragraph" && SETEXT_UNDERLINE.test(raw)) { + // A paragraph followed by `===` or `---` is a setext heading, not a break. + const heading = open; + open = null; + blocks.push({ type: "heading", line: heading.line, endLine: lineNo, text: [...heading.lines, raw].join("\n") }); + continue; + } + + if (THEMATIC_BREAK.test(raw)) { + close(lineNo - 1); + blocks.push({ type: "thematic_break", line: lineNo, endLine: lineNo, text: raw }); + continue; + } + + if (HEADING.test(raw)) { + close(lineNo - 1); + blocks.push({ type: "heading", line: lineNo, endLine: lineNo, text: raw }); + continue; + } + + if (BLOCKQUOTE.test(raw)) { + if (open?.type === "blockquote") { + open.lines.push(raw); + } else { + close(lineNo - 1); + open = { type: "blockquote", line: lineNo, lines: [raw] }; + } + continue; + } + + if (TABLE_ROW.test(raw)) { + close(lineNo - 1); + if (TABLE_SEPARATOR.test(raw)) continue; + splitCellsWithOffsets(raw).forEach(({ text: cell, offset }, column) => { + blocks.push({ type: "table_cell", line: lineNo, endLine: lineNo, text: cell, column, offset }); + }); + continue; + } + + if (LIST_ITEM.test(raw)) { + close(lineNo - 1); + open = { type: "list_item", line: lineNo, lines: [raw] }; + continue; + } + + // Plain text: a lazy continuation of whatever is open, else a new paragraph. + if (open) { + open.lines.push(raw); + } else { + open = { type: "paragraph", line: lineNo, lines: [raw] }; + } + } + close(lines.length); + return blocks; +} + +/** Split a pipe-table row into its cells, honouring `\|` escapes; the outer pipes are dropped. */ +export function splitCells(row: string): string[] { + return splitCellsWithOffsets(row).map((c) => c.text); +} + +/** + * The cells of a pipe-table row with the offset of each cell's trimmed text in + * the line, so a keyword found inside a cell can report the column it occupies + * in the file rather than in the cell. + */ +export function splitCellsWithOffsets(row: string): { text: string; offset: number }[] { + const boundaries: number[] = []; + for (let i = 0; i < row.length; i++) { + if (row[i] === "|" && row[i - 1] !== "\\") boundaries.push(i); + } + const first = row.search(/\S/); + const last = row.search(/\s*$/); + let start = boundaries[0] === first ? first + 1 : first; + const stops = boundaries.filter((b) => b >= start); + const endsWithPipe = stops.length > 0 && stops[stops.length - 1] === last - 1; + const cuts = endsWithPipe ? stops : [...stops, last]; + const cells: { text: string; offset: number }[] = []; + for (const cut of cuts) { + const raw = row.slice(start, cut); + const lead = raw.length - raw.trimStart().length; + cells.push({ text: raw.trim().replace(/\\\|/g, "|"), offset: start + lead }); + start = cut + 1; + } + return cells; +} diff --git a/ir/src/render/census.ts b/ir/src/render/census.ts new file mode 100644 index 0000000..12262d9 --- /dev/null +++ b/ir/src/render/census.ts @@ -0,0 +1,133 @@ +/** + * N52: `renderCensus()` -- the census report for a terminal (P4). + * + * Four panels, each an affordance a spec editor can read without having + * seen the IR (R1.9): the source census (U19), the provision census per + * source (U20), the unbound-occurrence table (U21), and the dependency-edge + * audit (U33). The migration worklist (U32) and exclusion roster (U34) + * belong to V3 and V6; their panels are absent rather than empty, so no + * reader mistakes a placeholder for a measurement. + */ +import type { ProvisionCensus } from "../census/provision-census.ts"; +import type { SourceCensus } from "../census/source-census.ts"; + +export function renderCensus(sources: SourceCensus, census: ProvisionCensus | null): string { + const out: string[] = []; + out.push(...renderSourceCensus(sources)); + if (census) { + out.push("", ...renderProvisionCensus(census), "", ...renderUnbound(census), "", ...renderEdgeAudit(census)); + } + return out.join("\n") + "\n"; +} + +function renderSourceCensus(sources: SourceCensus): string[] { + const lines = ["## Source census (U19)", ""]; + if (sources.problems.length > 0) { + lines.push("The declaration and the corpus disagree. Fix `ir/census/sources.yaml`:", ""); + for (const p of sources.problems) lines.push(`- ${p}`); + return lines; + } + const rows = sources.sources.map((s) => [ + s.path, + s.status, + s.normative_by ?? "", + (s.referenced_by ?? []).join(", "), + ]); + lines.push(...table(["document", "status", "normative by", "referenced by"], rows)); + const counts = count(sources.sources.map((s) => s.status)); + lines.push("", `${sources.sources.length} documents declared: ${Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(", ")}.`); + return lines; +} + +function renderProvisionCensus(census: ProvisionCensus): string[] { + const t = census.totals; + const lines = [ + `## Provision census (U20) -- ACS ${census.corpus.version ?? "?"} at ${census.corpus.commit?.slice(0, 7) ?? "unknown commit"}`, + "", + `${t.occurrences} RFC 2119 keyword occurrences in ${t.blocks_with_keywords} blocks across ${census.corpus.documents} documents; ${t.masked} inside inline code or comments.`, + `By keyword: ${entries(t.by_keyword)}.`, + `By block type: ${entries(t.by_block_type, true)}.`, + `Bound ${t.bound}, excluded ${t.excluded}, unbound ${t.unbound}.`, + "", + ]; + const rows = census.sources + .filter((s) => s.occurrences > 0 || s.normative_tags > 0 || s.footer_entries > 0) + .map((s) => [ + s.path, + s.status, + String(s.occurrences), + `${s.by_node_type.requirement}/${s.by_node_type.definition}/${s.by_node_type.invariant}/${s.by_node_type.exclusion}`, + String(s.bound), + String(s.excluded), + String(s.unbound), + String(s.normative_tags), + String(s.footer_entries), + ]); + lines.push(...table(["source", "status", "occ.", "REQ/DEF/INV/EXC", "bound", "excl.", "unbound", "tags", "footer"], rows)); + lines.push("", `${census.normative_tags.length} normative tags:`); + for (const tag of census.normative_tags) { + lines.push(`- ${tag.source}:${tag.line} [${tag.kind}] ${tag.title || tag.text.slice(0, 96)}`); + } + return lines; +} + +function renderUnbound(census: ProvisionCensus): string[] { + const lines = ["## Unbound and excluded occurrences (U21)", ""]; + const rows = census.occurrences + .filter((o) => o.binding.kind !== "bound") + .map((o) => [ + `${o.source}:${o.line}:${o.column}`, + o.keyword, + o.block_type, + o.binding.kind === "excluded" ? `excluded: ${o.binding.reason}` : "unbound", + o.context, + ]); + lines.push(...table(["location", "keyword", "block", "binding", "context"], rows)); + return lines; +} + +function renderEdgeAudit(census: ProvisionCensus): string[] { + const audit = census.dependency_audit; + const pillar = census.dependency_edges.filter((e) => e.kind === "pillar"); + const lines = [ + "## Dependency-edge audit (U33)", + "", + `${pillar.length} pillar entries in Referenced-by footers; ${audit.entries_without_depending_provision} with no provision depending back (provisions arrive in V2).`, + ]; + if (audit.pages_without_footer.length) lines.push(`Concept pages without a footer: ${audit.pages_without_footer.join(", ")}.`); + if (audit.dangling_targets.length) { + lines.push("Footer links to documents not in the corpus:"); + for (const d of audit.dangling_targets) lines.push(`- ${d.from}:${d.line} -> ${d.doc}`); + } + lines.push(""); + lines.push( + ...table( + ["from", "pillar", "targets", "depending provisions"], + pillar.map((e) => [ + `${e.from}:${e.line}`, + e.pillar ?? "", + e.targets.map((t) => (t.anchor ? `${t.doc}#${t.anchor}` : t.doc)).join(", "), + "(none yet)", + ]), + ), + ); + return lines; +} + +function entries(record: Record, dropZero = false): string { + return Object.entries(record) + .filter(([, v]) => !dropZero || v > 0) + .map(([k, v]) => `${k} ${v}`) + .join(", "); +} + +function count(values: string[]): Record { + const out: Record = {}; + for (const v of values) out[v] = (out[v] ?? 0) + 1; + return out; +} + +function table(header: string[], rows: string[][]): string[] { + const line = (cells: string[]): string => `| ${cells.map((c) => c.replace(/\|/g, "\\|")).join(" | ")} |`; + return [line(header), `|${header.map(() => "---").join("|")}|`, ...rows.map(line)]; +} diff --git a/ir/src/yaml.ts b/ir/src/yaml.ts new file mode 100644 index 0000000..90c3711 --- /dev/null +++ b/ir/src/yaml.ts @@ -0,0 +1,90 @@ +/** + * A small, deterministic YAML emitter for the generated census files. + * + * `Bun.YAML.stringify` exists and is used nowhere here on purpose: its + * output quotes and spaces keys in ways that make a regenerated file diff + * noisily against the committed one, and the generated census is a + * reviewable artifact whose whole value is a stable diff (R1.9, R7.2). + * This emitter writes block style only -- maps in insertion order, lists + * as `- ` items, scalars quoted only when YAML would otherwise misread + * them -- and `Bun.YAML.parse` reads it back. The authored files are + * parsed with `Bun.YAML.parse` and never written by a tool. + */ + +export type YamlValue = string | number | boolean | null | YamlValue[] | { [key: string]: YamlValue }; + +export function toYaml(value: YamlValue): string { + const lines: string[] = []; + emit(value, 0, lines); + return lines.join("\n") + "\n"; +} + +function emit(value: YamlValue, depth: number, lines: string[]): void { + const pad = " ".repeat(depth); + if (Array.isArray(value)) { + if (value.length === 0) { + lines.push(`${pad}[]`); + return; + } + for (const item of value) { + if (isScalar(item)) { + lines.push(`${pad}- ${scalar(item)}`); + } else { + // First line of the nested value shares the `- ` line. + const before = lines.length; + emit(item, depth + 1, lines); + const first = lines[before]; + if (first !== undefined) lines[before] = `${pad}- ${first.trimStart()}`; + } + } + return; + } + if (isScalar(value)) { + lines.push(`${pad}${scalar(value)}`); + return; + } + const entries = Object.entries(value); + if (entries.length === 0) { + lines.push(`${pad}{}`); + return; + } + for (const [key, item] of entries) { + if (item === undefined) continue; + if (isScalar(item)) { + lines.push(`${pad}${scalarKey(key)}: ${scalar(item)}`); + } else if ((Array.isArray(item) && item.length === 0) || (!Array.isArray(item) && Object.keys(item).length === 0)) { + lines.push(`${pad}${scalarKey(key)}: ${Array.isArray(item) ? "[]" : "{}"}`); + } else { + lines.push(`${pad}${scalarKey(key)}:`); + emit(item, depth + 1, lines); + } + } +} + +function isScalar(value: YamlValue): value is string | number | boolean | null { + return value === null || typeof value !== "object"; +} + +function scalarKey(key: string): string { + return /^[A-Za-z_][A-Za-z0-9_.-]*$/.test(key) ? key : JSON.stringify(key); +} + +function scalar(value: string | number | boolean | null): string { + if (value === null) return "null"; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return needsQuoting(value) ? JSON.stringify(value) : value; +} + +const RESERVED = /^(true|false|null|yes|no|on|off|~|y|n)$/i; + +function needsQuoting(s: string): boolean { + if (s.length === 0) return true; + if (s !== s.trim()) return true; + if (RESERVED.test(s)) return true; + if (/^[-+]?(\d[\d_]*(\.\d*)?|\.\d+)([eE][-+]?\d+)?$/.test(s)) return true; + if (/^0x[0-9a-fA-F]+$/.test(s)) return true; + if (/[\n\r\t"\\]/.test(s)) return true; + if (/^[-?:,\[\]{}#&*!|>'"%@`]/.test(s)) return true; + if (/: |:$| #/.test(s)) return true; + return false; +} diff --git a/ir/test/callout-scan.test.ts b/ir/test/callout-scan.test.ts new file mode 100644 index 0000000..e1cd4e2 --- /dev/null +++ b/ir/test/callout-scan.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { calloutScan } from "../src/census/callout-scan.ts"; + +const rules = readFileSync(join(import.meta.dir, "fixtures", "mini", "docs", "spec", "rules.md"), "utf8"); + +describe("calloutScan -- candidates for the editor, typed by tag shape only", () => { + const tags = calloutScan("spec/rules.md", rules); + + it("finds each of the four tag shapes at its line", () => { + expect(tags.map((t) => [t.line, t.kind, t.title])).toEqual([ + [21, "callout", "Chain hashing"], + [23, "lead_in", "Enforcement"], + [25, "heading", "1.1 Failure (normative)"], + [27, "citation", ""], + ]); + }); + + it("strips the quote marker from a callout's text and keeps the sentence", () => { + expect(tags[0]?.text).toBe("**Chain hashing (normative).** Entries MUST chain."); + }); + + it("finds nothing in a page without the tag", () => { + expect(calloutScan("x.md", "Plain MUST prose.\n\n> quoted, untagged")).toEqual([]); + }); +}); diff --git a/ir/test/corpus-reference-numbers.test.ts b/ir/test/corpus-reference-numbers.test.ts new file mode 100644 index 0000000..8b380a2 --- /dev/null +++ b/ir/test/corpus-reference-numbers.test.ts @@ -0,0 +1,103 @@ +/** + * The census against the pinned corpus, held to the numbers the shaping + * survey measured. `normative-ir-slices.md` §V1: "If V1's scan disagrees + * with those, V1's scan is wrong." The survey's numbers were taken at + * c259f57 (v0.1.0); the submodule now pins v0.1.2, which added two + * identity pages carrying five occurrences in table cells and left every + * other count unchanged. Both sets are recorded so a future submodule bump + * that moves a number moves it here, on purpose. + */ +import { describe, expect, it } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { defaultCensusDir, runCensus } from "../src/census/run-census.ts"; +import { defaultCorpusRoot } from "../src/corpus.ts"; + +const pinned = defaultCorpusRoot(); +const present = existsSync(join(pinned, "docs")); +const run = present ? runCensus() : null; +const census = run?.provisions ?? null; + +describe("the pinned corpus (spec/acs) -- reference numbers", () => { + it("is checked out", () => { + // A missing submodule is a hard failure here for the same reason it is in packages/guardian: + // the answer would otherwise be a self-skip that looks like a pass. + expect(present).toBe(true); + }); + + it("is fully declared in sources.yaml", () => { + expect(run?.sources.problems).toEqual([]); + }); + + it("is ACS v0.1.2 at 6fce2a0", () => { + expect(census?.corpus).toEqual({ version: "0.1.2", commit: "6fce2a0a71ed6372ed575ce19934dd32220b38d9", documents: 38 }); + }); + + it("carries 208 keyword occurrences in the four block types X3 found, and none anywhere else", () => { + expect(census?.totals.by_block_type).toEqual({ + paragraph: 145, + list_item: 33, + table_cell: 22, + blockquote: 8, + heading: 0, + code_fence: 0, + thematic_break: 0, + }); + expect(census?.totals).toMatchObject({ occurrences: 208, blocks_with_keywords: 134, masked: 0 }); + }); + + it("counts by keyword as the survey did, plus v0.1.2's five", () => { + expect(census?.totals.by_keyword).toEqual({ + "MUST NOT": 23, + MUST: 73, + "SHALL NOT": 0, + SHALL: 0, + "SHOULD NOT": 0, + SHOULD: 34, + "NOT RECOMMENDED": 0, + RECOMMENDED: 8, + MAY: 44, + OPTIONAL: 21, + REQUIRED: 5, + }); + }); + + it("leaves every occurrence in a normative source unbound, and excludes the eleven in informative or editorial sources", () => { + expect(census?.totals).toMatchObject({ bound: 0, excluded: 11, unbound: 197 }); + const excluded = (census?.occurrences ?? []).filter((o) => o.binding.kind === "excluded").map((o) => o.source); + expect(new Set(excluded)).toEqual( + new Set(["acs.md", "concepts/README.md", "identity/overview.md", "identity/standards.md", "topics/ACS_in_action_example.md"]), + ); + }); + + it("finds the ten (normative) callouts across six concept pages", () => { + const callouts = (census?.normative_tags ?? []).filter((t) => t.kind === "callout"); + expect(callouts.map((t) => `${t.source}:${t.line}`)).toEqual([ + "concepts/agents.md:15", + "concepts/agents.md:21", + "concepts/identity.md:21", + "concepts/intent.md:13", + "concepts/intent.md:21", + "concepts/provenance.md:19", + "concepts/provenance.md:27", + "concepts/session-lifecycle.md:23", + "concepts/trust.md:25", + "concepts/trust.md:27", + ]); + }); + + it("seeds dependency edges from all eight Referenced-by footers, none dangling", () => { + const pages = new Set((census?.dependency_edges ?? []).map((e) => e.from)); + expect(pages.size).toBe(8); + expect(census?.dependency_audit).toEqual({ + entries_without_depending_provision: 22, + pages_without_footer: [], + dangling_targets: [], + }); + }); + + it("matches the committed ir/census/provisions.yaml byte for byte", () => { + const committed = readFileSync(join(defaultCensusDir(), "provisions.yaml"), "utf8"); + expect(run?.yaml).toBe(committed); + }); +}); diff --git a/ir/test/fixtures/mini/docs/concepts/README.md b/ir/test/fixtures/mini/docs/concepts/README.md new file mode 100644 index 0000000..f7e7c00 --- /dev/null +++ b/ir/test/fixtures/mini/docs/concepts/README.md @@ -0,0 +1,3 @@ +# Concepts + +Invariants are tagged **(normative)**, e.g. "a thing MUST NOT change". diff --git a/ir/test/fixtures/mini/docs/concepts/thing.md b/ir/test/fixtures/mini/docs/concepts/thing.md new file mode 100644 index 0000000..eed6db2 --- /dev/null +++ b/ir/test/fixtures/mini/docs/concepts/thing.md @@ -0,0 +1,13 @@ +# Thing + +A thing is defined here. Nothing in this sentence is a keyword. + +> **Things are immutable (normative).** A thing MUST NOT change. + +--- + +**Referenced by** + +- **Instrument**, [rules](../spec/rules.md): the enforcement (§1), plus [hooks](../spec/hooks.md#anchor). +- **Trace**, [events](https://example.org/events): external. +- See also [Other](./other.md). diff --git a/ir/test/fixtures/mini/docs/notes.md b/ir/test/fixtures/mini/docs/notes.md new file mode 100644 index 0000000..2a39b15 --- /dev/null +++ b/ir/test/fixtures/mini/docs/notes.md @@ -0,0 +1,3 @@ +# Notes + +Readers MAY skip this page. diff --git a/ir/test/fixtures/mini/docs/spec/rules.md b/ir/test/fixtures/mini/docs/spec/rules.md new file mode 100644 index 0000000..a2b7f0f --- /dev/null +++ b/ir/test/fixtures/mini/docs/spec/rules.md @@ -0,0 +1,31 @@ +# Rules that MUST not count in a heading + +Intro paragraph. A client MUST send a handshake first, and it +MUST NOT send hooks before that. Inline `MUST` is a mention. + +- Item one SHOULD be short. + It MAY continue lazily. +- Item two + 1. nested MAY + 2. nested plain + +| Field | Required | Note | +|---|---|---| +| `a` | REQUIRED | escaped \| pipe | +| `b` | OPTIONAL, MAY be empty | none | + +```text +Fenced MUST not count. +``` + +> **Chain hashing (normative).** Entries MUST chain. + +**Enforcement (normative).** The framework MUST enforce it. + +### 1.1 Failure (normative) + +The concept is defined in [Thing](../concepts/thing.md) (normative). + +--- + +Trailing paragraph with RECOMMENDED default and NOT RECOMMENDED form. diff --git a/ir/test/fixtures/mini/sources.yaml b/ir/test/fixtures/mini/sources.yaml new file mode 100644 index 0000000..0b98fd2 --- /dev/null +++ b/ir/test/fixtures/mini/sources.yaml @@ -0,0 +1,16 @@ +sources: + - path: notes.md + status: informative + - path: concepts/README.md + status: editorial + pillar: concepts + - path: concepts/thing.md + status: normative + normative_by: reference + pillar: concepts + referenced_by: + - spec/rules.md#11-failure-normative + - path: spec/rules.md + status: normative + normative_by: self + pillar: instrument diff --git a/ir/test/fixtures/mini/version.txt b/ir/test/fixtures/mini/version.txt new file mode 100644 index 0000000..c5c9fb1 --- /dev/null +++ b/ir/test/fixtures/mini/version.txt @@ -0,0 +1 @@ +9.9.9 diff --git a/ir/test/keyword-scan.test.ts b/ir/test/keyword-scan.test.ts new file mode 100644 index 0000000..9bd2d66 --- /dev/null +++ b/ir/test/keyword-scan.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { keywordScan, maskSpans } from "../src/census/keyword-scan.ts"; + +const rules = readFileSync(join(import.meta.dir, "fixtures", "mini", "docs", "spec", "rules.md"), "utf8"); + +describe("keywordScan -- every RFC 2119 occurrence, with where it is", () => { + const scan = keywordScan("spec/rules.md", rules); + const rows = scan.occurrences.map((o) => `${o.line}:${o.column} ${o.keyword} ${o.block_type}`); + + it("finds the uses and none of the mentions", () => { + expect(rows).toEqual([ + "1:14 MUST heading", + "3:27 MUST paragraph", + "4:1 MUST NOT paragraph", + "6:12 SHOULD list_item", + "7:6 MAY list_item", + "9:13 MAY list_item", + "14:9 REQUIRED table_cell", + "15:9 OPTIONAL table_cell", + "15:19 MAY table_cell", + "21:42 MUST blockquote", + "23:44 MUST paragraph", + "31:25 RECOMMENDED paragraph", + "31:49 NOT RECOMMENDED paragraph", + ]); + }); + + it("counts MUST NOT once, as MUST NOT", () => { + expect(scan.occurrences.filter((o) => o.line === 4).map((o) => o.keyword)).toEqual(["MUST NOT"]); + }); + + it("reports the inline-code span and the HTML comment as masked, not as occurrences", () => { + expect(scan.masked.map((o) => `${o.line}:${o.column}`)).toEqual(["4:42", "4:67"]); + }); + + it("never looks inside a code fence", () => { + expect(scan.occurrences.some((o) => o.block_type === "code_fence")).toBe(false); + }); + + it("counts a block once however many keywords it carries", () => { + // heading, paragraph (3-4), item one (6-7), nested item, two cells, quote, lead-in, trailing paragraph + expect(scan.blocks_with_keywords).toBe(9); + const paragraph = scan.occurrences.filter((o) => o.line === 3 || o.line === 4); + expect(new Set(paragraph.map((o) => o.block)).size).toBe(1); + }); + + it("gives an editor a one-line context window around the keyword", () => { + const first = scan.occurrences.find((o) => o.line === 3); + expect(first?.context).toContain("A client MUST send a handshake first"); + expect(first?.context).not.toContain("\n"); + }); +}); + +describe("maskSpans", () => { + it("blanks code spans and comments to spaces of equal length, keeping newlines", () => { + const input = "a `MUST` b c ``d`e`` f"; + const masked = maskSpans(input); + expect(masked.length).toBe(input.length); + expect(masked).toBe("a " + " ".repeat(6) + " b " + " ".repeat(6) + "\n" + " ".repeat(5) + " c " + " ".repeat(7) + " f"); + }); +}); diff --git a/ir/test/markdown-blocks.test.ts b/ir/test/markdown-blocks.test.ts new file mode 100644 index 0000000..6c9a580 --- /dev/null +++ b/ir/test/markdown-blocks.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "bun:test"; +import { classifyBlocks, splitCells, splitCellsWithOffsets } from "../src/markdown-blocks.ts"; + +const sample = [ + "# Heading", + "", + "Para line one", + "para line two", + "", + "- item one", + " lazy continuation", + "- item two", + " 1. nested", + "", + "| h1 | h2 |", + "|---|---|", + "| c1 | c2 \\| esc |", + "", + "> quote one", + "> quote two", + "", + "```", + "fenced # not a heading", + "```", + "", + "---", + "", + "Setext", + "===", + "", + "!!! info \"Admonition\"", + " indented prose, not code", +].join("\n"); + +describe("classifyBlocks -- the block types X3 counted, at the lines they occupy", () => { + const blocks = classifyBlocks(sample); + const types = blocks.map((b) => `${b.type}@${b.line}`); + + it("names each construct once, in document order", () => { + expect(types).toEqual([ + "heading@1", + "paragraph@3", + "list_item@6", + "list_item@8", + "list_item@9", + "table_cell@11", + "table_cell@11", + "table_cell@13", + "table_cell@13", + "blockquote@15", + "code_fence@18", + "thematic_break@22", + "heading@24", + "paragraph@27", + ]); + }); + + it("keeps a lazy continuation inside its list item and a nested item as its own block", () => { + expect(blocks[2]?.text).toBe("- item one\n lazy continuation"); + expect(blocks[4]?.text).toBe(" 1. nested"); + }); + + it("emits one block per table cell, skips the separator row, and honours escaped pipes", () => { + const cells = blocks.filter((b) => b.type === "table_cell"); + expect(cells.map((c) => [c.column, c.text])).toEqual([ + [0, "h1"], + [1, "h2"], + [0, "c1"], + [1, "c2 | esc"], + ]); + }); + + it("joins consecutive quote lines into one blockquote block with the end line recorded", () => { + const quote = blocks.find((b) => b.type === "blockquote"); + expect(quote).toMatchObject({ line: 15, endLine: 16, text: "> quote one\n> quote two" }); + }); + + it("swallows a fence whole, including a line that looks like a heading", () => { + const fence = blocks.find((b) => b.type === "code_fence"); + expect(fence).toMatchObject({ line: 18, endLine: 20 }); + expect(blocks.some((b) => b.type === "heading" && b.text.includes("fenced"))).toBe(false); + }); + + it("reads a setext underline as a heading, not a thematic break", () => { + expect(blocks.filter((b) => b.type === "heading").map((b) => b.text)).toEqual(["# Heading", "Setext\n==="]); + }); + + it("treats indented admonition prose as paragraph text rather than an indented code block", () => { + expect(blocks.at(-1)).toMatchObject({ type: "paragraph", line: 27, endLine: 28 }); + }); + + it("skips YAML front matter", () => { + const withFrontMatter = ["---", "title: x", "---", "", "Body MUST count"].join("\n"); + expect(classifyBlocks(withFrontMatter)).toEqual([{ type: "paragraph", line: 5, endLine: 5, text: "Body MUST count" }]); + }); + + it("closes an unterminated fence at end of file", () => { + expect(classifyBlocks("```\nopen").map((b) => b.type)).toEqual(["code_fence"]); + }); +}); + +describe("splitCells", () => { + it("drops the outer pipes and trims each cell", () => { + expect(splitCells("| a | b|")).toEqual(["a", "b"]); + }); + it("keeps a trailing escaped pipe as content", () => { + expect(splitCells("| a \\|")).toEqual(["a |"]); + }); + it("reports where each cell's text starts in the line", () => { + expect(splitCellsWithOffsets("| a | b|")).toEqual([ + { text: "a", offset: 3 }, + { text: "b", offset: 7 }, + ]); + expect(splitCellsWithOffsets("a | b")).toEqual([ + { text: "a", offset: 0 }, + { text: "b", offset: 4 }, + ]); + }); +}); diff --git a/ir/test/run-census.test.ts b/ir/test/run-census.test.ts new file mode 100644 index 0000000..47a236e --- /dev/null +++ b/ir/test/run-census.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runCensus } from "../src/census/run-census.ts"; +import { main } from "../src/main.ts"; +import { renderCensus } from "../src/render/census.ts"; + +const mini = join(import.meta.dir, "fixtures", "mini"); +const sourcesFile = join(mini, "sources.yaml"); + +describe("runCensus -- the fixture corpus end to end", () => { + const run = runCensus({ corpusRoot: mini, sourcesFile }); + const census = run.provisions; + + it("declares the corpus cleanly and reads its version", () => { + expect(run.sources.problems).toEqual([]); + expect(run.corpus.version).toBe("9.9.9"); + expect(run.corpus.commit).toBeNull(); + expect(census?.corpus.documents).toBe(4); + }); + + it("puts every occurrence in exactly one of bound, excluded, unbound", () => { + expect(census?.totals).toMatchObject({ occurrences: 16, bound: 0, excluded: 2, unbound: 14, masked: 2 }); + const rows = census?.occurrences ?? []; + expect(rows.filter((r) => r.binding.kind === "excluded").map((r) => [r.source, (r.binding as { reason: string }).reason])).toEqual([ + ["concepts/README.md", "editorial_source"], + ["notes.md", "informative_source"], + ]); + }); + + it("reports per-source counts with the node-type columns present and zero", () => { + const rules = census?.sources.find((s) => s.path === "spec/rules.md"); + expect(rules).toMatchObject({ + occurrences: 13, + unbound: 13, + by_block_type: { heading: 1, paragraph: 5, list_item: 3, table_cell: 3, blockquote: 1 }, + by_node_type: { requirement: 0, definition: 0, invariant: 0, exclusion: 0 }, + normative_tags: 4, + }); + }); + + it("audits the footer edges: all unmatched, two dangling targets, no missing footers", () => { + expect(census?.dependency_audit).toEqual({ + entries_without_depending_provision: 2, + pages_without_footer: [], + dangling_targets: [ + { from: "concepts/thing.md", line: 11, doc: "spec/hooks.md" }, + { from: "concepts/thing.md", line: 13, doc: "concepts/other.md" }, + ], + }); + }); + + it("names a concept page that lacks a footer", () => { + const dir = mkdtempSync(join(tmpdir(), "acs-ir-")); + const sources = readFileSync(sourcesFile, "utf8").replace( + " - path: concepts/README.md\n status: editorial\n", + " - path: concepts/README.md\n status: normative\n normative_by: self\n", + ); + writeFileSync(join(dir, "sources.yaml"), sources); + const audit = runCensus({ corpusRoot: mini, sourcesFile: join(dir, "sources.yaml") }).provisions?.dependency_audit; + expect(audit?.pages_without_footer).toEqual(["concepts/README.md"]); + }); + + it("writes YAML that parses back to the same census and is byte-stable across runs", () => { + expect(run.yaml).not.toBeNull(); + const parsed = Bun.YAML.parse(run.yaml ?? "") as { totals: { occurrences: number }; occurrences: unknown[] }; + expect(parsed.totals.occurrences).toBe(16); + expect(parsed.occurrences).toHaveLength(16); + expect(runCensus({ corpusRoot: mini, sourcesFile }).yaml).toBe(run.yaml); + }); + + it("withholds the provision census when the source census has problems", () => { + const dir = mkdtempSync(join(tmpdir(), "acs-ir-")); + writeFileSync(join(dir, "sources.yaml"), "sources:\n - path: notes.md\n status: informative\n"); + const partial = runCensus({ corpusRoot: mini, sourcesFile: join(dir, "sources.yaml") }); + expect(partial.provisions).toBeNull(); + expect(partial.sources.problems).toContain("spec/rules.md: in the corpus but not declared in sources.yaml"); + expect(renderCensus(partial.sources, null)).toContain("Fix `ir/census/sources.yaml`"); + }); +}); + +describe("renderCensus -- the four V1 panels", () => { + const run = runCensus({ corpusRoot: mini, sourcesFile }); + const report = renderCensus(run.sources, run.provisions); + + it("renders the source census, provision census, unbound table and edge audit", () => { + for (const heading of ["## Source census (U19)", "## Provision census (U20)", "## Unbound and excluded occurrences (U21)", "## Dependency-edge audit (U33)"]) { + expect(report).toContain(heading); + } + expect(report).toContain("| spec/rules.md:3:27 | MUST | paragraph | unbound |"); + expect(report).toContain("| notes.md:3:9 | MAY | paragraph | excluded: informative_source |"); + expect(report).toContain("2 pillar entries in Referenced-by footers; 2 with no provision depending back"); + expect(report).toContain("- concepts/thing.md:11 -> spec/hooks.md"); + }); + + it("does not render the panels later slices own", () => { + expect(report).not.toContain("U32"); + expect(report).not.toContain("U34"); + }); +}); + +describe("acs-ir census -- exit codes", () => { + it("rejects an unknown command with usage", () => { + expect(main(["nope"])).toBe(2); + }); + + it("with --check reports a missing or stale file as 1 and a current file as 0", () => { + const dir = mkdtempSync(join(tmpdir(), "acs-ir-")); + const out = join(dir, "provisions.yaml"); + const args = ["census", "--corpus", mini, "--sources", sourcesFile, "--out", out, "--quiet"]; + expect(main([...args, "--check"])).toBe(1); + expect(main(args)).toBe(0); + expect(existsSync(out)).toBe(true); + expect(main([...args, "--check"])).toBe(0); + writeFileSync(out, readFileSync(out, "utf8") + "stale: true\n"); + expect(main([...args, "--check"])).toBe(1); + }); +}); diff --git a/ir/test/seed-depends-on.test.ts b/ir/test/seed-depends-on.test.ts new file mode 100644 index 0000000..041ea5a --- /dev/null +++ b/ir/test/seed-depends-on.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { seedDependsOn } from "../src/census/seed-depends-on.ts"; + +const thing = readFileSync(join(import.meta.dir, "fixtures", "mini", "docs", "concepts", "thing.md"), "utf8"); +const exists = (doc: string): boolean => ["spec/rules.md", "concepts/thing.md"].includes(doc); + +describe("seedDependsOn -- Referenced-by footers as candidate depends_on edges", () => { + const scan = seedDependsOn("concepts/thing.md", thing, exists); + + it("reports the footer and one edge per list item", () => { + expect(scan.has_footer).toBe(true); + expect(scan.edges.map((e) => [e.line, e.kind, e.pillar])).toEqual([ + [11, "pillar", "Instrument"], + [12, "pillar", "Trace"], + [13, "see_also", null], + ]); + }); + + it("resolves relative links against the page, splits anchors, and checks the target exists", () => { + expect(scan.edges[0]?.targets).toEqual([ + { doc: "spec/rules.md", anchor: null, label: "rules", external: false, exists: true }, + { doc: "spec/hooks.md", anchor: "anchor", label: "hooks", external: false, exists: false }, + ]); + }); + + it("keeps an external URL verbatim and never calls it dangling", () => { + expect(scan.edges[1]?.targets).toEqual([{ doc: "https://example.org/events", anchor: null, label: "events", external: true, exists: true }]); + }); + + it("marks a See-also entry as concept-to-concept rather than a pillar edge", () => { + expect(scan.edges[2]?.targets[0]).toMatchObject({ doc: "concepts/other.md", exists: false }); + }); + + it("reads only the footer, not body links", () => { + const body = "See [rules](../spec/rules.md) in the body.\n\nNo footer here."; + expect(seedDependsOn("concepts/x.md", body, exists)).toEqual({ source: "concepts/x.md", has_footer: false, edges: [] }); + }); + + it("stops at the first non-list line after the footer", () => { + const text = "**Referenced by**\n\n- **Instrument**, [r](../spec/rules.md)\n\nA closing paragraph with a [link](../spec/rules.md)."; + expect(seedDependsOn("concepts/x.md", text, exists).edges).toHaveLength(1); + }); +}); diff --git a/ir/test/source-census.test.ts b/ir/test/source-census.test.ts new file mode 100644 index 0000000..ff2512b --- /dev/null +++ b/ir/test/source-census.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "bun:test"; +import { parseSourceDeclarations, sourceCensus, type SourceDeclaration } from "../src/census/source-census.ts"; + +const declared: SourceDeclaration[] = [ + { path: "spec/a.md", status: "normative", normative_by: "self" }, + { path: "concepts/b.md", status: "normative", normative_by: "reference", referenced_by: ["spec/a.md#x"] }, + { path: "notes.md", status: "informative" }, +]; + +describe("sourceCensus -- the declaration and the tree must agree both ways", () => { + it("passes a fully declared corpus", () => { + expect(sourceCensus(declared, ["spec/a.md", "concepts/b.md", "notes.md"]).problems).toEqual([]); + }); + + it("names a corpus file nobody declared", () => { + expect(sourceCensus(declared, ["spec/a.md", "concepts/b.md", "notes.md", "new.md"]).problems).toEqual([ + "new.md: in the corpus but not declared in sources.yaml", + ]); + }); + + it("names a declared file that is not in the corpus, and a duplicate", () => { + const twice = [...declared, { path: "notes.md", status: "informative" as const }]; + expect(sourceCensus(twice, ["spec/a.md", "concepts/b.md"]).problems).toEqual([ + "notes.md: declared but not in the corpus", + "notes.md: declared more than once", + "notes.md: declared but not in the corpus", + ]); + }); + + it("requires a normative source to say what makes it normative", () => { + const problems = sourceCensus([{ path: "spec/a.md", status: "normative" }], ["spec/a.md"]).problems; + expect(problems).toEqual(["spec/a.md: normative sources must say what makes them normative (normative_by: self | reference)"]); + }); + + it("requires a by-reference source to cite a normative or editorial document in the corpus", () => { + const problems = sourceCensus( + [ + { path: "concepts/b.md", status: "normative", normative_by: "reference", referenced_by: ["notes.md#x", "gone.md"] }, + { path: "notes.md", status: "informative" }, + ], + ["concepts/b.md", "notes.md"], + ).problems; + expect(problems).toEqual([ + "concepts/b.md: referenced_by cites notes.md, which is declared informative; a citation carries normative force only from a normative or editorial source", + "concepts/b.md: referenced_by cites gone.md, which is not in the corpus", + ]); + expect(sourceCensus([{ path: "c.md", status: "normative", normative_by: "reference", referenced_by: [] }], ["c.md"]).problems).toEqual([ + "c.md: normative_by: reference needs a non-empty referenced_by", + ]); + }); + + it("rejects normative_by on a non-normative source", () => { + expect(sourceCensus([{ path: "n.md", status: "informative", normative_by: "self" }], ["n.md"]).problems).toEqual([ + "n.md: only normative sources carry normative_by / referenced_by", + ]); + }); +}); + +describe("parseSourceDeclarations -- the authored file's shape", () => { + it("parses the documented fields and nothing else", () => { + const parsed = parseSourceDeclarations( + "sources:\n - path: a.md\n status: normative\n normative_by: self\n pillar: x\n note: n\n extra: dropped\n", + ); + expect(parsed).toEqual([{ path: "a.md", status: "normative", normative_by: "self", pillar: "x", note: "n" }]); + }); + + it("throws on a missing list, a bad status, or a bad normative_by", () => { + expect(() => parseSourceDeclarations("nope: 1")).toThrow("expected a top-level `sources:` list"); + expect(() => parseSourceDeclarations("sources:\n - path: a.md\n status: maybe\n")).toThrow("status must be one of"); + expect(() => parseSourceDeclarations("sources:\n - path: a.md\n status: normative\n normative_by: vibes\n")).toThrow( + "normative_by must be self | reference", + ); + }); +}); diff --git a/ir/tsconfig.json b/ir/tsconfig.json new file mode 100644 index 0000000..13211e5 --- /dev/null +++ b/ir/tsconfig.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../tsconfig.base.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["**/node_modules"] +} diff --git a/package.json b/package.json index 6b920dc..0fa1448 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "acs-reference-implementation", "private": true, "type": "module", - "workspaces": ["packages/*", "hosts/*"], + "workspaces": ["packages/*", "hosts/*", "ir"], "scripts": { "test": "bun test", "typecheck": "tsc -p tsconfig.json --noEmit", @@ -11,7 +11,8 @@ "conformance": "bash scripts/run-conformance.sh", "watch:upstream": "bash scripts/run-upstream-watch.sh", "guardian": "bun run packages/guardian/src/main.ts", - "inspector": "bun run packages/inspector/src/main.ts" + "inspector": "bun run packages/inspector/src/main.ts", + "ir": "bun run ir/src/main.ts" }, "dependencies": { "agent-control-specification": "0.3.1-beta.0" diff --git a/tsconfig.json b/tsconfig.json index 30cdd7f..a1e8c36 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "compilerOptions": { "rootDir": "." }, - "include": ["packages/**/*.ts", "hosts/**/*.ts", "test/**/*.ts"], + "include": ["packages/**/*.ts", "hosts/**/*.ts", "test/**/*.ts", "ir/**/*.ts"], "exclude": [ "**/node_modules", // server.test.ts's outer-net test copies packages/guardian/src here at