diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index 6f8ed6b..9ebbc88 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -45,6 +45,7 @@ 1fb7dd2afe2b230d7c335db5c5da4e72e0ad271ce2dcd1683f446945e0abb917 conformance/materialization/011_discriminator_envelope/input.yaml 2020cd3da7008987b81055f0ef1bc7481567202da7f8bf49d616b1018f52bc21 mk/infra.mk 2063bf8ac333858a9b5c96de552fc4879ca3c35e5d0e34b0dd4067cec94bb5da Dockerfile +214885986c75f2a6928c9e2a8326804353310ade3834815c5d64f3e74634ddbf project.yaml 21a2f539315cb8500c9d812776d0e777b2eca73d668b3079b694b651fe8539d8 conformance/materialization/012_discriminator_payload_keywords/input.yaml 21a9994fa283ffca9aa0d063526ba4bc8b7d53d781bfbbee0d0041dcc04d143a conformance/invalid/005_origin_in_authoring_input/schema.yaml 21a9994fa283ffca9aa0d063526ba4bc8b7d53d781bfbbee0d0041dcc04d143a conformance/invalid/006_unknown_primitive/schema.yaml @@ -52,7 +53,6 @@ 225e266950c6f85bfd2be20cacb3de0ac011414f92010b831136640ffe9a46be conformance/invalid/007_sealed_missing_path/expected-error.yaml 229376bac93d6b5d81c8c3015b4981999cf3a3c82bf8a8e1d63eecaf61d1e35d conformance/validation/007_origin_duplicate_term/expected-error.yaml 24219339b219898ecb69d09495bc078745cef2c1b5065eedfff886dd95032881 conformance/invalid/010_required_value_missing/expected-error.yaml -25330918bb1de80bf3db79532e39dd87e2752df46e402434c0044de476046f86 README.md 2758d20cf5aa0688060bce28a52786eee272986714a8376d7f190eb2dc90a43e reviews/README.md 275acc4de6f4d5fcd87330039777ceb8f8d8a7933c4e4924eccabe21a9f28da2 conformance/materialization/002_origin_schema/meta.yaml 27fc384a0d7987e4cb311648408b9b5a3995c0b58871ed55389ae154061209c9 conformance/validation/011_more_than_one_document/object.yaml @@ -155,6 +155,7 @@ 72c48fa014dea46236640cf52037631aa5db63a6f58807b3bf24577a247aa238 tools/check_doc_links.yaml 72e9b7c69632c1e1877e9f9d69812f3259003989b69f6b0fa7f23612d4514c94 tools/releaselib/git_service.yaml 7426752feb1140b2023192728df35248e4f802617c150bb0523ab97dc1638c0b tools/init-hooks.sh +753f2eb2a6b70c2e35e76b61baa67b9f9533f8f86713da7b9ff62ab44def6ccb docs/pending-decisions.md 75b59a6cf6a2ce8af4fd04c8a0dcf7d33e5e5f132a48a981252ce61c05cf97c9 docs/hu/makefile-cheatsheet.md 75d086d878abe5943b2dd0c080c0ffb4fd88b517289c216ffa6bc5c1f12056f7 conformance/invalid/003_closure_undeclared/input.yaml 764d101d6bb896af8c4f082768f30f9f7cf77a6f0a0fed940b7b39805330d842 requirements.txt @@ -211,6 +212,7 @@ 94dabea4d0bdb7a81c750c4b2830f096c9ee2e59952fd1b9f7d4e235cddeb2f9 mk/golang.mk 96a0612d7f3d7d3faf96bbc408da0b32979eaee2599962e8d6d9e97c15b38d40 conformance/materialization/015_nested_sealed_template/schema.yaml 992423396d17250586acefe1ca589c21d650fb75c7f578bdff9cbb42fa1351fa conformance/invalid/012_scalar_position_sequence_payload/expected-error.yaml +998c8f3ef914d9447a6f565be986c0c8d3459b55b6a131290609d912b709e542 SPEC.md 9c11fd643268e578437b015dc5a4ea6e5a3f290f0303ac04ea4d429768208617 tools/schemalib/__init__.py 9c2c853fb20be48e4ebbfdf70a98e6890df116fd77fd85a6be2b010674afcaab go/conformance/conformance_test.go 9d0d594b86072a88bbda400992cb62d0b4faf71176a76c8f30c2e632c8eb5fb1 conformance/validation/011_more_than_one_document/meta.yaml @@ -238,6 +240,7 @@ aad20e1630b166e5ca74baad558d0f47cc9be1d6b6aea22c23deebeaa75447fc conformance/ma ac2343c803409938f2414e5833bab1ae33061506fe2678307121837036e3ada2 conformance/invalid/003_closure_undeclared/schema.yaml af1074d4241e3ac01c7b4197ffcef9e270dbde4c4ddccd7b1d6c0aab4a75cbb0 docs/hu/architecture.md af6cd3c7db5089246c1c7533fb2763351d03dab7c26424a8827e347fdaee618e tools/schemalib/artifact.yaml +b1f4c81b89b74764e424b6f963615944c64b5c11d3e61ef8027334836a51b18d README.md b24ec80c06a16f685f69c69a2b2d266b823e45d0153868b10967df484d9eafee conformance/invalid/010_required_value_missing/meta.yaml b35767f17012df3e8334e7414d1d535c60a3a6e83c7a3038e6c8ea4915c48bdb conformance/validation/002_origin_empty/object.yaml b3d0d0dd71c294b5fa8bad4fcdd9784feae35141aeb71e1755abd8bbd0cfca58 pyproject.toml @@ -273,7 +276,6 @@ c874c3372e425b2255c6eb1771e13f77fe6025923d06ced7b5a91259e28e1d22 go/objectmodel c8daecc8e4b81241d10c642c6c051712022bb8950a7855fd31d7805534de9586 conformance/materialization/006_closure_opaque/expected.yaml cadf923c73fdea6b9e1478f822f30d3f4d6c7463b15a12df709f0ae5d56743a2 conformance/validation/007_origin_duplicate_term/meta.yaml cc2109e262ef7d2c0ae924af62ae6b794934a37c9be09e35f1a175b22faa2483 go/objectmodel/construct.go -cc22d2735173aaf85a9944852f4522df726185bd7f8b023085fd7d0c5d7fe6d4 project.yaml cc94b0652d20afaf3b85ee72e50913f0277b6441642309670818355847f1e721 spec/node.schema.yaml ccc12224c9262ebed8b7bedb3046e9a359fa817e9c08659765d62d63355d0e3a conformance/materialization/014_hostile_keys/input.yaml cdc268dd275143f3cbad6b84078f397367c40951812143d227b5490cd007ce82 tools/infra.py @@ -334,7 +336,6 @@ fa3d02b5fc0677eb715d676e88b98b94016a76eb13e47abfa1040d0e6f12719f docs/rust-gate fa8afcf490e0b9b1e3ce94789f3233ed73289553ec94f16d2c347afd5e08b367 conformance/invalid/002_sealed_yaml_schema_conflict/input.yaml fab14c32130c2345945fa55f41e6614bc443815733249fce81ed7d97e1d1f4cf rust/src/value.rs fd4ba258680da20f4084db2696fa942c09e47de8ec4650104f2b2e18c53980bc go/module/adversarial_test.go -fdd8b8d94d94d9c08aafdc2b0e6f85db5deba627541786a8d72ef99c3eb37049 SPEC.md ff005c27b6c185b065c2121b01fa6f71c9668af920483714c9b137e411cec32c go/objectmodel/emit_test.go ff5d544dc1e253e76844a7c325f7f73d952ac29b284092f0ed2e80b8de3ae0a2 go/objectmodel/materialize.go ffa488df0e6ea07872d7c2b8a7ed0b692e4a998711991e4471cfc134fcbaf0b6 go/objectmodel/api_test.go diff --git a/README.md b/README.md index 81b3f7e..2232f97 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ written into SPEC.md and propagated nowhere else. | `SPEC.md` — 46 numbered invariants | written, normative | | `conformance/` — 38 vectors | executed against Go on every CI run | | `docs/spec-vector-map.md` | 37 invariants vector-covered, 5 declared unvectorizable with a reason each | -| `tools/check_spec_vectors.py` | runs and passes; negative-tested | +| `tools/check_spec_vectors.py` | runs and passes; checks the invariant index, not RFC-2119 clauses | | `go/` | implemented — corpus, fuzz, mutation, adversarial and CLI golden tests | | `rust/` | implemented — corpus, reader, rejection and CLI golden tests | | `mk/rust.mk` | present; digest-pinned toolchain, `make rust.quality` | @@ -74,6 +74,7 @@ conformance/ the falsifiable part — YAML in, YAML out validation/ canonical object -> accept / reject docs/ spec-vector-map.md every invariant -> its vectors, or why it has none + pending-decisions.md questions the spec does not answer and code does decision-delta.md what this model changes in D-003 / D-011 migration-surface.md the measured file list this model would change branch-decision.md why base-repo wasm/main diff --git a/SPEC.md b/SPEC.md index 5f94bc3..0bf835d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1205,6 +1205,10 @@ The procedure, and the three commissioning prompts a request carries, are in ## 13. Related documents +- [`docs/pending-decisions.md`](docs/pending-decisions.md) — questions this + document does not answer, which an implementation has answered anyway. Twelve + of them, three answered differently in the two implementations. Read this + before adding a rule: several of these are load-bearing and unwritten. - [`docs/spec-defects.md`](docs/spec-defects.md) — where this document is not executable, measured by implementing it. **Read SD-017 before relying on INV-033.** diff --git a/docs/pending-decisions.md b/docs/pending-decisions.md new file mode 100644 index 0000000..453b7c6 --- /dev/null +++ b/docs/pending-decisions.md @@ -0,0 +1,279 @@ +# Pending decisions + +Questions this specification does not answer, where an implementation has +answered them anyway. + +That is the distinction worth holding onto. A defect is a place where the +document says something wrong; these are places where it says **nothing**, and +something had to happen, so whatever the first implementation did became the +answer. Nobody chose it. It is not written down. It is load-bearing. + +`docs/spec-defects.md` records the first kind. This file records the second. + +Each entry states the question, what happens today and whether that is a +decision or an accident, the options with what each costs, and what it blocks. +None of them is fixed by writing more code: they are settled by choosing, and +then by a vector that holds both implementations to the choice. + +Sources: the three external review threads against `cbaf928`, recorded in +[`../reviews/`](../reviews/). Findings closed since are in the commit history; +what remains is here. + +--- + +## D-1 — Does `scalar_type` constrain a value, or describe it? + +**Measured:** neither implementation reads `scalar_type` during validation. It +is parsed, carried, and emitted into the `shape` primitive, and nothing compares +it to the payload. `scalar_type: integer` with a string value materializes and +validates. + +**Accident.** The arity check added after the audit refuses a *collection* at a +scalar position; it deliberately did not touch subtype, because answering that +question in an emitter is how a specification acquires rules nobody wrote. + +| option | cost | +|---|---| +| **Constraining** — the payload must match | needs a type lattice (what is `integer` for a YAML `1.0`? for `0x10`?), coercion rules or their explicit absence, and a vector per type | +| **Descriptive** — an annotation the model carries and does not check | cheap and honest, but then `shape.scalar_type` in a canonical object is a claim no one verified, and a module reading it is trusting the author | + +**Blocks:** any consumer that treats a canonical object as typed data. +**Related:** audit semantic F-04, claim F-11. + +--- + +## D-2 — What happens to a declared position that is absent, optional, and has no default? + +**Measured:** Go removes the node. Rust materializes a scalar as `null`, a list +as empty, and walks object children. `docs/spec-defects.md` SD-007 already +records three readings — omit, null, reject — and calls none of them decided. + +**Accident, and a divergence the corpus cannot see:** no vector has an absent +optional position. + +| option | cost | +|---|---| +| **Omit** — the node does not exist | matches Go; a reader cannot distinguish "not configured" from "not declared" without the schema | +| **Materialize as null** | matches Rust; every optional position becomes a node with a value nobody wrote, and `origin` has no term for that | +| **Reject** — every declared position needs a value or a default | strictest and simplest to state; makes optionality a schema error rather than a runtime state | + +**Blocks:** cross-implementation agreement outside the corpus. +**Related:** audit semantic F-05, claim F-03. + +--- + +## D-3 — May instance input author a primitive the schema declares, and if so, does it replace or merge? + +**Measured:** vector `011_discriminator_envelope` supplies an `access` primitive +from the instance and it REPLACES the declaration. Nothing states whether that +is allowed in general, and nothing states what happens when both the schema and +the instance declare parts of the same primitive. + +**Accident.** One vector fixes one case; the rule is unwritten. + +| option | cost | +|---|---| +| **Forbidden** — primitives come from the schema only | INV-036 nearly says this already; would invalidate vector 011 as written | +| **Replacement** — the instance value wins whole | what happens today for the one case that exists; simple, and loses schema-declared members silently | +| **Merge** — leaf-wise override | most useful, most to specify: merge order, list handling, and what `origin` says about a merged node | + +**Blocks:** D-4, because a merged primitive's origin is undefined. +**Related:** audit semantic F-01. + +--- + +## D-4 — What origin does an injected primitive-internal default carry? + +**Measured:** `inherit` is injected into every access operation that does not +declare it, and it takes the origin of the *enclosing* primitive — `[yaml]` in +vector 011, `[schema]` in 013. So a value the YAML did not contain is recorded +as yaml-authored. + +**Accident, and it makes an origin false.** `origin` is a claim about who +authored a value; the author did not write this one. + +| option | cost | +|---|---| +| **Enclosing origin** — today's behaviour | keeps the object simple; the provenance of an injected default is wrong, which is the one thing origin exists to be right about | +| **Always `[schema]`** | truthful — the model supplied it — and makes a node's origin differ from its parent's inside one primitive, which nothing else does | +| **Do not inject** — absence means the default | nothing to attribute; a policy decision point must then know the default, which §6.4 says it should not have to | + +**Related:** audit semantic F-02. + +--- + +## D-5 — Is `access` a grammar or a shape? + +**Measured:** the operations are checked (`read`, `modify`, and `write` is +refused). Nothing else is: `inherit` may be any value, an operation block may +carry any member, and `rules` may be anything at all. + +**Accident.** INV-024 through INV-026 describe value domains in prose that no +code reads. + +| option | cost | +|---|---| +| **Enforce** — closed member set, typed values | the primitive becomes a real contract; needs a grammar in §6.4 and a vector family | +| **Leave open** — `access` is a shape the model carries | honest, cheap, and means a policy decision point cannot rely on anything about its content | + +**Related:** audit semantic F-03. +**Note:** this is the same shape of question as **F-08 of the first audit** — +the `contract` primitive, which §8.7 requires to be enforced and which nothing +evaluates. Both are "a primitive the specification describes and no code +checks". They should be decided together or the answer will not be consistent. + +--- + +## D-6 — What characters may a node name contain? + +**Measured:** a child named `a.b` materializes. `Path()` returns +`$.values.a.b`, and `Get` splits it into two segments, so the address does not +round-trip. Key quoting was fixed for serialization; addressing was not, and the +round-trip test in `addressing_test.go` passes only because no vector uses such +a name. + +**Accident, and INV-040 is false for it.** Every node has exactly one address — +except these, which have none that resolves. + +| option | cost | +|---|---| +| **Restrict names** — a declared character set | simplest; a schema with `a.b` becomes a schema-load error; opaque payload keys stay unrestricted and unaddressable, which needs saying | +| **Escape in the address grammar** | keeps names free; every address becomes harder to read and both resolvers need the escape | +| **Length-prefixed or quoted segments** | unambiguous, and no longer looks like a path | + +**Blocks:** INV-040 being true as stated. +**Related:** audit claim F-04, semantic F-09. + +--- + +## D-7 — Which YAML is the input dialect? + +**Measured:** Go uses `gopkg.in/yaml.v3`, Rust uses `saphyr`. Anchors, aliases, +duplicate keys and non-string keys are now refused explicitly, in both, because +each was found to diverge. Tags, `!!binary`, and version directives are not +mentioned anywhere and are handled by whatever each library does. + +**Accident.** Three divergences in this area have been found by review; the +remainder is untested rather than agreed. + +| option | cost | +|---|---| +| **Name a version and a tag policy** — e.g. YAML 1.2 core schema, no tags | states the contract; both implementations need checking against it | +| **Define an input profile** — an explicit subset the model reads | strongest, and the most work: every construct outside it must be refused by name | + +**Related:** audit semantic F-11. + +--- + +## D-8 — Are the pipeline stage names and error codes normative? + +**Measured:** every rejection carries a stage and a code, the vectors assert +them, and **§8 does not list them**. `schema-load` is not a stage the +specification names at all (`docs/spec-defects.md` SD-002). The corpus is the +only place the vocabulary exists. + +**Accident.** A second implementation must match strings it can only learn by +reading fixtures. + +| option | cost | +|---|---| +| **Make them normative** — §8 lists stages, an appendix lists codes | a table, and then the vectors check the specification rather than defining it | +| **Declare them non-normative** — diagnostics, not contract | then the vectors must stop asserting them, and a caller cannot branch on a code | + +**Related:** audit semantic F-13. + +--- + +## D-9 — What does INV-032 require of a language that cannot express it? + +**Measured:** Go cannot satisfy the construction guarantee — an external struct +embedding the interface satisfies it, which the repository documents in SD-019 +and defends against at runtime. Rust satisfies it structurally. The invariant is +stated as a property of the type system, so one shipped implementation does not +meet the normative text. + +**A defect the repository already admits, and a decision it has not made.** + +| option | cost | +|---|---| +| **Restate as boundary behaviour** — validate, bind representations, snapshot once | achievable in both; loses the "unforgeable by construction" claim that made INV-032 interesting | +| **Keep the strong form and mark Go non-conformant on it** | honest; means the reference implementation fails a normative requirement, in writing | +| **Two levels** — a required boundary contract, plus a stronger construction property where the language allows | more text, and describes what is actually true | + +**Related:** audit claim F-05, semantic F-14; SD-019. + +--- + +## D-10 — What does the corpus mapping gate actually promise? + +**Measured:** `check_spec_vectors.py` builds its universe by matching `INV-\d+` +tokens after the §12 heading. It does not read RFC 2119 keywords — zero +occurrences of `MUST` in the tool — so an unnumbered `MUST` cannot enter +coverage at all, and §8's stage requirements are full of them. It also accepts +any non-empty justification cell without checking that the alternate check +exists. No test exercises it. + +**Accident.** The README calls it "negative-tested"; nothing under `tests/` +references it. + +| option | cost | +|---|---| +| **Require every RFC-2119 clause to belong to an invariant** | makes the gate's promise true; a large editing pass over §8 | +| **Parse clauses directly and track them** | no editing pass, a harder tool, and a coverage number that finally means what it says | +| **Narrow the claim** — the gate checks the invariant index and says so | free, and leaves unnumbered MUSTs untested by design rather than by accident | + +**Related:** audit claim F-06. + +--- + +## D-11 — What are the resource budgets? + +**Measured:** none, at any public entry point. No limit on input bytes, node +count, nesting depth, output bytes, time or memory. The only bound is template +expansion depth (64, now in both). Two amplification findings have been closed — +alias expansion and a quadratic scan — and both were found by review rather than +by a budget refusing them. + +**Accident.** The adversarial review marked recursion depth, memory and output +"inconclusive, not defended" because it could not execute; the absence of any +budget is not inconclusive. + +| option | cost | +|---|---| +| **Per-entry-point budgets** — bytes in, nodes, depth, bytes out | bounded work, and every limit becomes a number someone has to defend | +| **Depth only** — the recursive walks are the crash risk | cheapest real improvement; says nothing about a wide-and-shallow input | +| **Document the absence** | free, and moves the risk to every caller | + +**Related:** audit adversarial, resource section. + +--- + +## D-12 — Does `make release` implement INV-045? + +**Measured:** it does not. The release path hashes one canonical source file, +clears `buildHash`, expects a build artifact this repository does not produce, +and signs a five-field metadata subset. `release_subject.py` — which computes +the INV-045 subject — is not called by it. The descriptor schema was corrected +to accept `repo_type: spec`, but nothing runs that validation either. + +**A gap, not an accident:** the release machinery is inherited from a template +for a different kind of repository. + +| option | cost | +|---|---| +| **Replace the path** — one command that freezes, computes, verifies, review-binds and signs the subject | the honest fix; discards inherited machinery that does not fit | +| **Wrap it** — keep the Vault chain, feed it the subject digest | less to write, keeps a state machine nobody here needs | + +**Blocks:** producing a release that demonstrably satisfies INV-045. +**Related:** audit claim F-02. + +--- + +## How these get decided + +Not by me and not by an implementation. Each one is settled by an entry in +`SPEC.md`, a vector that holds both implementations to it, and a line here +saying which way it went and why the alternatives were refused. + +Until then, every one of them is answered — by accident, in code, differently in +two places in at least three of the cases above. diff --git a/project.yaml b/project.yaml index f8811db..b11451e 100644 --- a/project.yaml +++ b/project.yaml @@ -49,7 +49,7 @@ metadata: # # Recompute with `make release.subject`; check with `make release.verify`, # which a third party can run with a clone and a Python and nothing else. - buildHash: '830f62fda35e03d00836602fe9ab7d6237209f1c83277c5b79c10cf691605d4f' + buildHash: '90f232d2fede1efd83bbdd4b7e6e5928030edaa9b67adf6d67fe300a405ad4c8' cicSign: 'TBD' cicSignedCA: certificate: "TBD — filled by the release process with the CIC Root CA certificate"