Skip to content

feat(api)!: select rules by Rule id + the cookie conformance domain - #4

Open
parkrevil wants to merge 15 commits into
mainfrom
feat/rule-enum-selection
Open

feat(api)!: select rules by Rule id + the cookie conformance domain#4
parkrevil wants to merge 15 commits into
mainfrom
feat/rule-enum-selection

Conversation

@parkrevil

@parkrevil parkrevil commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Two things ship together:

  • Rule-only selection. ashward(url, [Rule.OriginReflection]) now works. Previously the name you could say (Rule.X) was not the thing you could pass (rules.x, a RuleDef). Breaking: ALL_RULES, ALL_RULE_IDS, the rules.* namespace, and RuleDef/HttpRuleContext/HttpTarget/ProbeFn are removed. Public surface is 9 names.
  • Cookie conformance domain. 32 new rules plus a consumer simulator, with the catalog extracted into a shared conformance catalog.

Also: an empty result set no longer passes (ranNothing), and cookieSim is wired to the public options.

Correction. An earlier version of this description claimed ranNothing was widened to "empty or all-skip". That is not implemented — ok-policy.ts:52 is still results.length === 0, and an all-Skip run is still green. The all-Skip widening is designed but unstarted. See review finding 3.

Release

Two pending changesets, both minor0.3.0. The selection change is breaking, but the package is 0.x and coverage(), Report.counts and the Severity redesign are still open — 1.0.0 would read as a stability claim it can't back.

Verification

993 pass / 0 fail · coverage 100% line / 99.13% branch (gate 95%) · tsgo --noEmit clean · oxlint --type-aware clean · oxfmt --check clean · build clean. CI verify green. Rebased onto main @ 0.2.0.

Not merge-ready: six correctness findings from review reproduce against this head and are unfixed. See the review thread.

🤖 Generated with Claude Code

parkrevil and others added 14 commits August 8, 2026 17:02
…ixture

Structural cleanup, zero behavior change (773 tests unchanged throughout):

- Extract the clause->rule conformance catalog (types, registry,
  disposition-enums, per-standard modules) out of src/standards/ into a
  new src/catalog/ layer. standards/ was fusing the normative-source
  corpus with the catalog, and the catalog's Rule-enum reference was the
  tree's only bidirectional module edge (core <-> standards). Now
  standards/ is a pure L0 corpus and the module graph is a single acyclic
  DAG (standards -> core -> catalog/http -> rules -> api).
- Break the standards/interfaces <-> standards/types file-level import
  cycle by defining StandardDocument beside its member interfaces.
- Hoist the duplicated origin.test HttpTarget fixture (69 spec files) into
  src/testkit/target.ts, matching the existing testkit fixture pattern;
  per-spec run() helpers stay local (rule-coupled, no shared abstraction).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…keletons

Separated-TDD Pass 1 for the cookie conformance domain (PLAN-COOKIE.md);
specs + skeletons only, rule judging logic is Pass 2.

- src/catalog/cookie.ts — 51 clauses (32 rule-bearing across sections A-D,
  18 untestable residue, no-combine), implementing the two-axis model.
- addressee axis — new Addressee enum + Clause.addressee; registry.spec.ts
  scopes the MUST->Fail / SHOULD->Warn / Unmarked->Warn invariants to
  server clauses and adds the user-agent invariant (discard->Fail,
  alter->Warn, severityNote required). Pre-existing clauses default to
  Server via build.clause(), so no other catalog module changed.
- 32 Rule ids; 32 flat rule skeletons returning a NotImplemented sentinel;
  32 colocated specs from the fires/silent table + spec vectors + do-not-flag
  negatives; the consumer-simulation kit as types-only (cookie-sim.ts).
- corpus — RFC 6265bis modeled as a Draft LivingDocument (no RFC number yet).

RED verified: 182 cookie assertions fail against the sentinel (verdict
mismatch, never a compile/import error); 775 pre-existing tests stay green;
typecheck / lint / oxfmt clean; the module DAG stays acyclic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ation

Implements the judging logic that turns the Pass-1 (RED) cookie specs green;
the locked specs/catalog were not touched (one Pass-1 fixture typo excepted).

- src/rules/kit/cookie-sim.ts — the consumer simulation (RFC 6265bis 5.6
  parse + 5.7 selection); the request-context-dependent steps stay unmodelled
  per the plan's 0.5.
- src/rules/kit/cookie-grammar.ts, cookie-rule.ts — the raw 4.1 grammar
  splitter and the defineCookieRule factory.
- src/normative/ — one-concept predicates: the lenient 5.1.1 cookie-date
  parser, 5.1.2 host canonicalization, 5.1.3 domain-match (IP-guarded), 5.1.4
  default-path, a versioned public-suffix snapshot, and the cookie-octet /
  av-octet / CHAR octet classes; http-date.ts gained isImfFixdate (additive).
- the 32 rule bodies across grammar Fail / server SHOULD / UA discard / UA alter.
- fix a Pass-1 fixture typo in alter-lifetime-advisory.spec.ts (a Max-Age too
  small to exceed a 10-day limit) — flagged by the implementer, corrected by
  the contract owner rather than distorting the implementation.

962 tests pass, 0 fail; typecheck / lint / oxfmt clean; the per-file 95%
coverage gate is met; the module DAG stays acyclic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…view

Green tests passed, but a three-engine review (codex/grok/Claude) plus a
re-review found 11 real divergences from RFC 6265bis that the ASCII / valid-
input fixtures had missed. Each is fixed RED-first — a failing test added that
exposes the wrong verdict, then the implementation.

- octet vs UTF-16: the 4096 / 1024 size gates measured JS string length, not
  octets, so a multibyte value could slip past. New normative/octet-length.ts;
  every gate (cookie-sim, discard-oversize-4096, alter-attribute-dropped-1024,
  alter-path-rescoped, and the alter-max-age/expires eligibility filters) now
  measures octets.
- cookie-date: the 5.1.1 day/year/time productions permit a trailing non-digit
  run; the anchored regexes wrongly rejected legal forms like the glued
  `10:18:14GMT` and `1st Jan 2020 ...`.
- reachesPrefixStep: added the 5.7 Domain-CHAR and public-suffix gates that
  precede the prefix steps, so a cookie discarded earlier no longer mis-
  attributes the discard to the prefix rule.
- cookie-value-grammar: scan the WSP-trimmed value (BWS around `=` belongs to
  forbidden-wsp, not the value octet check); internal SP still fails.
- alter-nameless-stored: an empty `Set-Cookie:` is discarded, not stored.
- host-prefix-contract: select the last Path (findLast), matching 5.7.
- cookie-host: reject R-LDH (`xn--`) labels as Fake A-labels, yielding an
  indeterminate verdict (honest reduced scope; ashward does not implement IDNA).
- forbidden-wsp: an all-WSP cookie-av is a 4.1.1 deviation.

979 tests pass, 0 fail; typecheck / lint / oxfmt clean; per-file 95% coverage;
module DAG stays acyclic; the locked catalog/spec contract was not touched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-safe

Two issues in the cookie host canonicalizer, found in a final-pass review:

- A prior round rejected EVERY `xn--` label to avoid accepting a Fake A-label,
  which over-cautiously turned a genuine A-label (e.g. `xn--bcher-kva`, real
  punycode) into an indeterminate verdict. Use real UTS-46 ToASCII via the
  `URL` global: a valid A-label now yields a definite verdict, a Fake A-label
  (`xn--bogus`) makes `new URL` throw and the rows stay indeterminate.
- `new URL('http://' + host + '/')` silently reinterprets a host that carries
  URL-authority/path syntax — `evil@example.com` parses as userinfo and returns
  hostname `example.com`, wrongly giving a bogus host a definite verdict. Guard
  the host against any non-LDH octet (and `_`, and raw non-ASCII — the request
  host is already ToASCII via resolve-target) BEFORE the URL parse.

canonicalizeHost verified directly across 16 host shapes (valid / fake
A-labels, underscore, non-xn-- R-LDH, the URL-delimiter misparse set, leading
hyphen, IPv4/IPv6, uppercase). 981 tests pass, 0 fail; typecheck / lint /
oxfmt clean; per-file 95% coverage; module DAG acyclic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…icalization

Follow-up review of the host canonicalizer:
- Route the IP-literal branch through the same URL parse as the DNS branch, so a
  malformed literal (`[garbage`, `evil:garbage`, an out-of-range dotted quad) is
  rejected to indeterminate instead of being passed through by the loose shape
  check.
- Enforce RFC 5890's 63-octet LDH-label limit.
- Document the deliberate reduced scope: canonicalizeHost reuses the WHATWG
  normalizer that resolve-target already applies to derive the request host (so
  it is idempotent on every host reaching it through the public API), not a
  bit-exact IDNA2008 validator — an A-label whose punycode decodes to a
  disallowed code point (an emoji) and a raw U-label (which never arrives raw)
  are out of scope for a black-box oracle that never probes such hosts.

983 tests pass, 0 fail; typecheck / lint / oxfmt clean; per-file 95% coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the RFC 6265bis cookie section to README.md / README.ko.md (producer
conformance §4 + consumer-consequence §5, the two-axis model) and a minor
changeset for the new domain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lic API

Two defects a consumer's field report surfaced, both confirmed by execution
before and after the fix.

- A run that judged zero clauses reported ok. `ashward(url, [])` — or any
  caller whose `ALL_RULES.filter(...)` happens to select nothing — returned
  `ok() === true` with an empty result set, i.e. a green gate that verified
  nothing. `resolveOk` now treats an empty result set as not-ok
  unconditionally, the same "a conformance gate that ran nothing must not go
  green" principle ok-policy already applied to connectivity failures, and
  `assertOk` throws a message that says so instead of an empty failure list.
- `cookieSim` was unreachable from the public API. Four shipped cookie rules
  read `HttpRuleContext.cookieSim`, but `AshwardOptions` only threaded
  `reflect`, so a real supercookie (`Set-Cookie: a=b; Domain=com`) was reported
  indeterminate to every consumer while the rule itself judges it fail. Threaded
  end-to-end through `runHttp`, mirroring `reflect`, preserving the documented
  three-state `ageLimitDays`.

README / README.ko document the new option; the cookie changeset is amended.
989 tests pass, 0 fail; typecheck / lint / oxfmt clean; per-file 95% coverage;
module DAG acyclic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`ashward(url, [Rule.OriginReflection])` now works. The name a caller could
say (`Rule.X`) was not the thing they could pass (`rules.x`), so the enum
was useful only for comparing results and every selection needed the other
vocabulary.

The selection parameter becomes `readonly Rule[]`, resolved internally by
`resolveRules` in `rules/all.ts`, which de-duplicates by id (first wins —
the engine maps the selection with no uniqueness check, so a repeat would
put two results under one `ruleId`) and throws RangeError on an id with no
definition, since ids arrive as data from config files and CLI flags.

BREAKING: `ALL_RULES` becomes `ALL_RULE_IDS`; the `rules.*` namespace,
`RuleDef`, `HttpRuleContext`, `HttpTarget` and `ProbeFn` are no longer
exported. Passing a custom `RuleDef` — including substituting one under a
shipped id, which `ashward`'s doc comment advertised — goes with them; that
capability is removed deliberately, not because it did not work. Its test
moves to `runHttp`, which keeps the `RuleDef[]` seam and is the right layer
for "which path did the probe hit". `AshwardOptions`, `CookieSimConfig` and
`ReflectMode` are now exported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit replaced `ALL_RULES` with `ALL_RULE_IDS`, which was a second roster under a
second name: its own spec asserted it equals `Object.values(Rule)` element-for-element, so the two
were provably the same list. Shipping both is the exact defect this work exists to remove — one
vocabulary means one.

The default selection is now `Object.values(Rule)`, and a caller scoping a run writes
`Object.values(Rule).filter(id => id !== Rule.X)`. Nothing is lost: the roster-completeness spec
still pins every `Rule` member to exactly one shipped definition, so the enum cannot drift from
what actually runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adding `Rule`-id selection left two overlaps that were mine, not pre-existing:

- `api/rule-selection.spec.ts` tested the public entry alongside `api/ashward.spec.ts`,
  including a duplicate empty-selection case. Merged into `ashward.spec.ts`; one file per
  public entry point.
- `runHttp` had integration specs in two directories after the custom-RuleDef test moved to
  `http/`. `core/engine/run.integration.spec.ts` imported `runHttp` from `http/` anyway, so
  both now live in `http/run.integration.spec.ts` beside the binder they exercise.

No test is dropped: 3 runHttp integration tests and 7 ashward tests, all still asserting what
they did before. Verified separately that the four exported-but-locally-used symbols the scan
reports (Rfc9112ClauseId, FramingRuleSpec, DecodedBody, StandardLocator) are unchanged from
before this branch — none is residue from removing the RuleDef surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Rfc9112ClauseId`, `FramingRuleSpec`, `DecodedBody` and `StandardLocator` were exported but
referenced only inside their own module. Each is the odd one out among its own siblings, which is
what makes them leftovers rather than a deliberate surface:

- every other catalog ClauseId is cited 11-26 times by rule files; rfc9112 is cited 0, because its
  two framing rules hand-author their `normative` arrays instead of calling `refsFor`
- eight of the nine rule kits keep their `*Spec` interface module-private; framing-rule did not
- `DecodedBody` is the only exported type alias under `http/decode`
- `StandardLocator` is the only export in `standards/interfaces.ts` with no external reference

These predate the Rule-id selection work — verified by running the same scan against 619490f, which
reports the identical four. Being pre-existing is not a reason to leave them, so they go now rather
than being recorded as known residue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A working design document that `git add -A` swept into 9fb65cb along with the rule-selection
change. It does not belong in the package.

Its measured baseline and the reasoning behind the coverage/selection design are carried forward
in HANDOFF.md, together with the two sections it got wrong: the severity redesign (scoped to 90
mappings when the real gap is one bit on 18 cookie user-agent clauses) and the placement of
coverage() in src/catalog/ (rules -> catalog is 91 files, the reverse is 0, so that direction is a
cycle).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The package is 0.x; a major changeset would publish 1.0.0 and read as an API
stability claim while coverage(), Report.counts and the Severity redesign are
still open. Breaking changes stay breaking — they ship as 0.3.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@parkrevil parkrevil left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

I am requesting changes based on six reproducible correctness issues. I reviewed these against the current PR head (8042fe8), the PR description and commit history, the repository's own rule-ownership conventions, and the current RFC 6265bis editor's draft. I did not modify the branch.

1. Raw Set-Cookie octets are changed by UTF-8 decoding

Affected code: src/http/decode/head-parse.ts:33, src/normative/octet-length.ts:9, src/rules/kit/cookie-sim.ts:86-88,210-218, src/rules/discard-oversize-4096.ts:18-21

parseResponseHead() decodes every field line with the default TextDecoder. Invalid UTF-8 octets become U+FFFD, after which octetLength() UTF-8-encodes the changed string. This alters both lengths and values before the cookie rules inspect them.

Reproduction through the complete rule execution path:

wire Set-Cookie value: "a=" + 1366 × 0xFF
actual name + value length: 1367 octets
discard-oversize-4096 result: Fail

Each 0xFF becomes U+FFFD and is subsequently counted as three UTF-8 octets, so a cookie far below 4096 octets is reported as discarded for exceeding the limit. The same problem affects the 1024-octet attribute limit.

This also changes semantics, not just lengths. With Set-Cookie: a=b; Domain=K (wire bytes E2 84 AA) and target host k, Unicode toLowerCase() maps to ASCII k; both discard-domain-char and discard-domain-mismatch return Pass. Those original octets are outside CHAR, so the cookie must be discarded at the Domain CHAR step.

RFC 6265bis requires parsing the individual octets without decoding them, and RFC 9110 says obs-text (%x80-FF) is opaque data:

This contradicts the cookie implementation's stated octet-exact behavior and produces both false Fail and false Pass results.

2. The reduced PSL produces both false Pass and false Fail verdicts

Affected code: src/normative/public-suffix.ts:16-56, src/rules/discard-public-suffix.ts:33-34, src/rules/discard-host-prefix.ts:17-22

The reduced list is deliberate, but its documented claim that omissions are conservative and can never create false Fail results is incorrect. publicSuffixConfigured: true exposes definitive Pass/Fail results while the implementation recognizes only 35 literal suffixes and supports neither wildcard nor exception rules.

Reproduced false Pass:

target host: foo.xyz
Set-Cookie: a=b; Domain=xyz
cookieSim: { publicSuffixConfigured: true }
discard-public-suffix result: Pass
expected: Fail (xyz is an ICANN public suffix)

Reproduced false Fail:

target host: xyz
Set-Cookie: __Host-a=1; Secure; Path=/; Domain=xyz
cookieSim: { publicSuffixConfigured: true }
discard-host-prefix result: Fail
expected: Pass

At RFC 6265bis section 5.7 step 9, a public-suffix Domain identical to the request host is cleared. The cookie therefore becomes host-only and satisfies the __Host- contract. Because xyz is absent from the reduced set, that clearing never occurs. co.uk works as the control case only because it is hard-coded.

Wildcard PSL rules fail similarly (*.ck / !www.ck). Sources:

Thus this is not merely incomplete coverage: the public option can return incorrect conclusions in both directions.

3. The PR claims all-Skip runs are blocked, but they remain green

Affected code: src/core/report/ok-policy.ts:51-52, src/core/assert/assert-ok.ts:18-25

The current PR description states that ranNothing was widened to cover an empty result set or all-Skip results. The accepted follow-up design also distinguishes this from treating individual skips as failures. However, the implementation remains:

return results.length === 0;

Reproduction:

results: [{ verdict: Skip, reason: HeaderAbsent }]
report.ok(): true
assertOk(report): does not throw

The history matters here: commit 8c36639 originally intended to block empty results only, and the existing test still explicitly expects a Skip-only report to be OK. The later all-Skip requirement was designed but not implemented, while the current PR description presents it as shipped. Therefore this is a concrete mismatch between the PR's declared scope and the code, rather than evidence that every individual Skip should fail.

4. Legal HTAB OWS after ; is reported as an invalid cookie attribute

Affected code: src/rules/cookie-av-shape.ts:10-25, src/rules/kit/cookie-grammar.ts:11-27

Reproduction:

Set-Cookie: a=b;\tSecure

Actual results:

cookie-av-shape: Fail
forbidden-wsp: Pass

RFC 6265bis defines this boundary as ";" OWS cookie-av, and RFC 9110 defines OWS as zero or more SP/HTAB octets. rawAvSegments() retains the leading HTAB, and cookie-av-shape then tests it as though it were part of cookie-av; HTAB is not an av-octet, so the valid OWS is falsely reported as a producer grammar violation.

The adjacent forbidden-wsp implementation already recognizes leading WSP after ; as legal OWS, confirming the intended ownership at this boundary.

5. Later section 5.7 discard rules fire after an earlier step has already aborted

Affected code: src/rules/discard-samesite-none-insecure.ts:20-34, src/rules/discard-nameless-prefix-value.ts:16-24, src/rules/kit/cookie-sim.ts:276-309, src/rules/discard-secure-prefix.ts:21-30

Several consequence rules report a later discard cause even when the cookie never reaches that RFC step.

Examples reproduced through rule.run():

target: site.example
Set-Cookie: a=b; Domain=other.example; SameSite=None
actual: discard-domain-mismatch Fail + discard-samesite-none-insecure Fail
expected attribution: Domain mismatch only; step 10 aborts before step 19
Set-Cookie: =__Secure-a; SameSite=None
actual: discard-nameless-prefix-value Fail
expected attribution: SameSite=None without Secure aborts at step 19 before step 22
Set-Cookie: __Secure-a=1; SameSite=None
actual: SameSite rule Fail + Secure-prefix rule Fail
expected attribution: step 19 aborts before the prefix step

The final conclusion that the cookie is discarded remains true, but the named rule attribution is false. This is inconsistent with the repository's stated intent: discard-oversize-4096 explicitly excludes cookies aborted by the preceding CTL step, prefix-rule comments say they count only cookies that reach the prefix step, and reachesPrefixStep() was introduced specifically to model earlier Domain/PSL aborts. The gating is only partial.

6. Two different malformed fields are reported as duplicate cookie names

Affected code: src/rules/no-duplicate-name-across-fields.ts:16-19

Reproduction:

Set-Cookie: foo
Set-Cookie: bar

Actual results:

cookie-pair-equals: Fail
no-duplicate-name-across-fields: Fail

Neither field has a section 4.1.1 cookie-pair or cookie-name. The producer duplicate-name rule nevertheless feeds both values through the section 5.6 consumer parser, which maps every equals-less value to name === ""; the two unrelated malformed fields are then treated as duplicate names.

The adjacent producer rules cookie-name-token and cookie-name-nonempty deliberately exclude equals-less fields because cookie-pair-equals owns that malformed shape. The duplicate-name rule should follow the same producer-rule boundary rather than deriving a synthetic empty name from the consumer parser.

Verification

  • Existing suite: 993 pass / 0 fail
  • tsgo --noEmit: pass
  • type-aware lint: pass
  • build: pass
  • Each finding above was separately reproduced against the current PR head through the relevant rule/report execution path.

report.ok() returned true and assertOk() returned silently when every selected rule
skipped, so a CI gate went green having judged nothing. A skip is a result but not a
judgment — the rule reached the response and found its precondition absent — so an
all-skip run is the same vacuum as running no rules, which ranNothing already refused
to green.

Widening that one predicate closes both gates: assertOk never calls resolveOk, but
both call ranNothing. [].every() is true, so the empty case falls out of the same
check rather than needing its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@parkrevil
parkrevil force-pushed the feat/rule-enum-selection branch from 96cf7e5 to 07636a4 Compare August 11, 2026 03:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant