diff --git a/artifacts/compiled/traj-example-grafana-login-nav.bundle.json b/artifacts/compiled/traj-example-grafana-login-nav.bundle.json index 4db49f8..29a326c 100644 --- a/artifacts/compiled/traj-example-grafana-login-nav.bundle.json +++ b/artifacts/compiled/traj-example-grafana-login-nav.bundle.json @@ -1,22 +1,27 @@ { "schema_version": "1.0.0", "bundle_kind": "compiled_trajectory", - "source_trajectory_id": "traj-example-grafana-login-nav", - "site_key": "grafana-oss@example", - "task_key": "open-dashboards-list", + "source_trajectory_id": "traj-gate-fixture-login-dashboards", + "site_key": "grafana-oss@fixture", + "task_key": "login-open-dashboards-list", "compiled_at": "2026-07-25T00:00:00.000Z", "compiler": { "version": "0.1.0-b3", "notes": "One CacheRow per step; locator_fallback_chain in B2 preference order; assertions synthesized from post_state (+ hints). pool_eligible fail-closed.", - "input_path": "contracts/examples/trajectory.example.json" + "input_path": "experiments/gate-v1/trajectories/grafana-fixture-login-dashboards.json" }, "rows": [ { "schema_version": "1.0.0", - "row_id": "cache-grafana-oss@example-open-dashboards-list-0", - "site_key": "grafana-oss@example", - "task_key": "open-dashboards-list", + "row_id": "cache-grafana-oss@fixture-login-open-dashboards-list-0", + "site_key": "grafana-oss@fixture", + "task_key": "login-open-dashboards-list", "step_index": 0, + "program": { + "program_id": "prog-traj-gate-fixture-login-dashboards", + "steps_total": 6, + "compiled_at": "2026-07-25T00:00:00.000Z" + }, "compiled_action": { "type": "navigate", "locator_fallback_chain": [], @@ -24,13 +29,13 @@ "host", "port" ], - "url_template": "http://{host}:{port}/login" + "url_template": "http://{host}:{port}/grafana-gate-login.html" }, "assertion": { "schema_version": "1.0.0", - "assertion_id": "assert-open-dashboards-list-step-0", + "assertion_id": "assert-login-open-dashboards-list-step-0", "type": "element-visible", - "strength": "strong", + "strength": "weak", "target": { "locator": { "strategy": "role_name", @@ -42,7 +47,7 @@ }, "timeout_ms": 5000, "failure_classification": "assertion_failed", - "notes": "Presence of the login/control surface after navigate is strong evidence we reached the intended page (chrome locator, not tenant content)." + "notes": "New landmark(s) in post_state are coarse structural signals — consistent with success but not unambiguous proof. Labelled weak." }, "confidence": 0, "success_count": 0, @@ -57,10 +62,15 @@ }, { "schema_version": "1.0.0", - "row_id": "cache-grafana-oss@example-open-dashboards-list-1", - "site_key": "grafana-oss@example", - "task_key": "open-dashboards-list", + "row_id": "cache-grafana-oss@fixture-login-open-dashboards-list-1", + "site_key": "grafana-oss@fixture", + "task_key": "login-open-dashboards-list", "step_index": 1, + "program": { + "program_id": "prog-traj-gate-fixture-login-dashboards", + "steps_total": 6, + "compiled_at": "2026-07-25T00:00:00.000Z" + }, "compiled_action": { "type": "fill", "locator_fallback_chain": [ @@ -68,12 +78,12 @@ "strategy": "role_name", "role": "textbox", "name": "Username", - "tenant_scoped": false + "tenant_scoped": true }, { "strategy": "label", "label": "Username", - "tenant_scoped": false + "tenant_scoped": true }, { "strategy": "testid", @@ -82,7 +92,7 @@ }, { "strategy": "structural", - "structural_path": "main > form > input:nth-of-type(1)", + "structural_path": "body > main > section:nth-of-type(1) > form > input:nth-of-type(1)", "tenant_scoped": false } ], @@ -92,14 +102,83 @@ }, "assertion": { "schema_version": "1.0.0", - "assertion_id": "assert-open-dashboards-list-step-1", + "assertion_id": "assert-login-open-dashboards-list-step-1", "type": "element-visible", "strength": "weak", "target": { "locator": { + "strategy": "testid", + "testid": "username-input" + } + }, + "expected": { + "visible": true + }, + "timeout_ms": 5000, + "failure_classification": "assertion_failed", + "notes": "Fill/select cannot assert the typed value (values are param slots, never stored). Visibility of the target control is only weakly consistent with a successful fill." + }, + "confidence": 0, + "success_count": 0, + "failure_count": 0, + "last_verified_at": "2026-07-25T00:00:00.000Z", + "pool_eligible": true, + "pool_ineligible_reason": null, + "flow_topology": { + "prev_action_type": "navigate", + "next_action_type": "fill", + "landmark": "main" + } + }, + { + "schema_version": "1.0.0", + "row_id": "cache-grafana-oss@fixture-login-open-dashboards-list-2", + "site_key": "grafana-oss@fixture", + "task_key": "login-open-dashboards-list", + "step_index": 2, + "program": { + "program_id": "prog-traj-gate-fixture-login-dashboards", + "steps_total": 6, + "compiled_at": "2026-07-25T00:00:00.000Z" + }, + "compiled_action": { + "type": "fill", + "locator_fallback_chain": [ + { "strategy": "role_name", "role": "textbox", - "name": "Username" + "name": "Password", + "tenant_scoped": true + }, + { + "strategy": "label", + "label": "Password", + "tenant_scoped": true + }, + { + "strategy": "testid", + "testid": "password-input", + "tenant_scoped": false + }, + { + "strategy": "structural", + "structural_path": "body > main > section:nth-of-type(1) > form > input:nth-of-type(2)", + "tenant_scoped": false + } + ], + "param_refs": [ + "password" + ] + }, + "assertion": { + "schema_version": "1.0.0", + "assertion_id": "assert-login-open-dashboards-list-step-2", + "type": "element-visible", + "strength": "weak", + "target": { + "locator": { + "strategy": "testid", + "testid": "password-input" } }, "expected": { @@ -116,7 +195,197 @@ "pool_eligible": true, "pool_ineligible_reason": null, "flow_topology": { - "prev_action_type": "navigate", + "prev_action_type": "fill", + "next_action_type": "click", + "landmark": "main" + } + }, + { + "schema_version": "1.0.0", + "row_id": "cache-grafana-oss@fixture-login-open-dashboards-list-3", + "site_key": "grafana-oss@fixture", + "task_key": "login-open-dashboards-list", + "step_index": 3, + "program": { + "program_id": "prog-traj-gate-fixture-login-dashboards", + "steps_total": 6, + "compiled_at": "2026-07-25T00:00:00.000Z" + }, + "compiled_action": { + "type": "click", + "locator_fallback_chain": [ + { + "strategy": "role_name", + "role": "button", + "name": "Log in", + "tenant_scoped": true + }, + { + "strategy": "testid", + "testid": "login-button", + "tenant_scoped": false + }, + { + "strategy": "structural", + "structural_path": "body > main > section:nth-of-type(1) > form > button", + "tenant_scoped": false + } + ] + }, + "assertion": { + "schema_version": "1.0.0", + "assertion_id": "assert-login-open-dashboards-list-step-3", + "type": "url-matches", + "strength": "strong", + "target": { + "url_template": "http://{host}:{port}/grafana-gate-login.html#home" + }, + "expected": { + "template": "http://{host}:{port}/grafana-gate-login.html#home", + "regex_template": "^http://[^/?#]+:[^/?#]+/grafana-gate-login\\.html#home$", + "param_types": { + "host": "string", + "port": "integer" + } + }, + "timeout_ms": 5000, + "failure_classification": "assertion_failed", + "notes": "URL template changed (or navigate completed); matching post_state.url_template is strong evidence the step reached the intended surface." + }, + "confidence": 0, + "success_count": 0, + "failure_count": 0, + "last_verified_at": "2026-07-25T00:00:00.000Z", + "pool_eligible": false, + "pool_ineligible_reason": "literal_in_assertion", + "flow_topology": { + "prev_action_type": "fill", + "next_action_type": "click", + "landmark": "main" + } + }, + { + "schema_version": "1.0.0", + "row_id": "cache-grafana-oss@fixture-login-open-dashboards-list-4", + "site_key": "grafana-oss@fixture", + "task_key": "login-open-dashboards-list", + "step_index": 4, + "program": { + "program_id": "prog-traj-gate-fixture-login-dashboards", + "steps_total": 6, + "compiled_at": "2026-07-25T00:00:00.000Z" + }, + "compiled_action": { + "type": "click", + "locator_fallback_chain": [ + { + "strategy": "role_name", + "role": "link", + "name": "Dashboards", + "tenant_scoped": true + }, + { + "strategy": "testid", + "testid": "nav-dashboards", + "tenant_scoped": false + }, + { + "strategy": "structural", + "structural_path": "body > main > section:nth-of-type(2) > header > nav > a:nth-of-type(2)", + "tenant_scoped": false + } + ] + }, + "assertion": { + "schema_version": "1.0.0", + "assertion_id": "assert-login-open-dashboards-list-step-4", + "type": "url-matches", + "strength": "strong", + "target": { + "url_template": "http://{host}:{port}/grafana-gate-login.html#dashboards" + }, + "expected": { + "template": "http://{host}:{port}/grafana-gate-login.html#dashboards", + "regex_template": "^http://[^/?#]+:[^/?#]+/grafana-gate-login\\.html#dashboards$", + "param_types": { + "host": "string", + "port": "integer" + } + }, + "timeout_ms": 5000, + "failure_classification": "assertion_failed", + "notes": "URL template changed (or navigate completed); matching post_state.url_template is strong evidence the step reached the intended surface." + }, + "confidence": 0, + "success_count": 0, + "failure_count": 0, + "last_verified_at": "2026-07-25T00:00:00.000Z", + "pool_eligible": false, + "pool_ineligible_reason": "literal_in_assertion", + "flow_topology": { + "prev_action_type": "click", + "next_action_type": "click", + "landmark": "main" + } + }, + { + "schema_version": "1.0.0", + "row_id": "cache-grafana-oss@fixture-login-open-dashboards-list-5", + "site_key": "grafana-oss@fixture", + "task_key": "login-open-dashboards-list", + "step_index": 5, + "program": { + "program_id": "prog-traj-gate-fixture-login-dashboards", + "steps_total": 6, + "compiled_at": "2026-07-25T00:00:00.000Z" + }, + "compiled_action": { + "type": "click", + "locator_fallback_chain": [ + { + "strategy": "role_name", + "role": "button", + "name": "Dismiss", + "tenant_scoped": true + }, + { + "strategy": "testid", + "testid": "dismiss-notice", + "tenant_scoped": false + }, + { + "strategy": "structural", + "structural_path": "body > main > section:nth-of-type(2) > div:nth-of-type(1) > button", + "tenant_scoped": false + } + ] + }, + "assertion": { + "schema_version": "1.0.0", + "assertion_id": "assert-login-open-dashboards-list-step-5", + "type": "element-visible", + "strength": "strong", + "target": { + "locator": { + "strategy": "testid", + "testid": "dismiss-notice" + } + }, + "expected": { + "visible": false + }, + "timeout_ms": 5000, + "failure_classification": "assertion_failed", + "notes": "Recorder observed the acted-on control was no longer visible after the action (ADR-0007 post_action_target_visible=false). Asserts only that: the control is gone. Proves the step was not a no-op; proves nothing about downstream state." + }, + "confidence": 0, + "success_count": 0, + "failure_count": 0, + "last_verified_at": "2026-07-25T00:00:00.000Z", + "pool_eligible": false, + "pool_ineligible_reason": "literal_in_assertion", + "flow_topology": { + "prev_action_type": "click", "landmark": "main" } } diff --git a/artifacts/compiled/traj-gate-live-create-stat-dashboard-from-testdata-9.5.21.bundle.json b/artifacts/compiled/traj-gate-live-create-stat-dashboard-from-testdata-9.5.21.bundle.json index 01a1dbd..a412a9b 100644 --- a/artifacts/compiled/traj-gate-live-create-stat-dashboard-from-testdata-9.5.21.bundle.json +++ b/artifacts/compiled/traj-gate-live-create-stat-dashboard-from-testdata-9.5.21.bundle.json @@ -124,7 +124,7 @@ "failure_count": 0, "last_verified_at": "2026-07-25T00:00:00.000Z", "pool_eligible": false, - "pool_ineligible_reason": "tenant_locator_text", + "pool_ineligible_reason": "literal_in_assertion", "flow_topology": { "prev_action_type": "navigate", "next_action_type": "click", @@ -190,8 +190,8 @@ "success_count": 0, "failure_count": 0, "last_verified_at": "2026-07-25T00:00:00.000Z", - "pool_eligible": false, - "pool_ineligible_reason": "tenant_locator_text", + "pool_eligible": true, + "pool_ineligible_reason": null, "flow_topology": { "prev_action_type": "click", "next_action_type": "click", @@ -251,8 +251,8 @@ "success_count": 0, "failure_count": 0, "last_verified_at": "2026-07-25T00:00:00.000Z", - "pool_eligible": false, - "pool_ineligible_reason": "tenant_locator_text", + "pool_eligible": true, + "pool_ineligible_reason": null, "flow_topology": { "prev_action_type": "click", "next_action_type": "fill", @@ -321,8 +321,8 @@ "success_count": 0, "failure_count": 0, "last_verified_at": "2026-07-25T00:00:00.000Z", - "pool_eligible": false, - "pool_ineligible_reason": "tenant_locator_text", + "pool_eligible": true, + "pool_ineligible_reason": null, "flow_topology": { "prev_action_type": "click", "next_action_type": "fill", @@ -385,8 +385,8 @@ "success_count": 0, "failure_count": 0, "last_verified_at": "2026-07-25T00:00:00.000Z", - "pool_eligible": false, - "pool_ineligible_reason": "tenant_locator_text", + "pool_eligible": true, + "pool_ineligible_reason": null, "flow_topology": { "prev_action_type": "fill", "next_action_type": "fill", @@ -504,7 +504,7 @@ "failure_count": 0, "last_verified_at": "2026-07-25T00:00:00.000Z", "pool_eligible": false, - "pool_ineligible_reason": "tenant_locator_text", + "pool_ineligible_reason": "literal_in_assertion", "flow_topology": { "prev_action_type": "fill", "next_action_type": "click", @@ -565,8 +565,8 @@ "success_count": 0, "failure_count": 0, "last_verified_at": "2026-07-25T00:00:00.000Z", - "pool_eligible": false, - "pool_ineligible_reason": "tenant_locator_text", + "pool_eligible": true, + "pool_ineligible_reason": null, "flow_topology": { "prev_action_type": "click", "next_action_type": "fill", @@ -630,8 +630,8 @@ "success_count": 0, "failure_count": 0, "last_verified_at": "2026-07-25T00:00:00.000Z", - "pool_eligible": false, - "pool_ineligible_reason": "tenant_locator_text", + "pool_eligible": true, + "pool_ineligible_reason": null, "flow_topology": { "prev_action_type": "click", "next_action_type": "click", @@ -702,7 +702,7 @@ "failure_count": 0, "last_verified_at": "2026-07-25T00:00:00.000Z", "pool_eligible": false, - "pool_ineligible_reason": "tenant_locator_text", + "pool_ineligible_reason": "literal_in_assertion", "flow_topology": { "prev_action_type": "fill", "next_action_type": "click", @@ -766,7 +766,7 @@ "failure_count": 0, "last_verified_at": "2026-07-25T00:00:00.000Z", "pool_eligible": false, - "pool_ineligible_reason": "tenant_locator_text", + "pool_ineligible_reason": "literal_in_assertion", "flow_topology": { "prev_action_type": "click", "landmark": "main" diff --git a/docs/README.md b/docs/README.md index 38a4a8b..8a9e25b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -67,6 +67,7 @@ Integrity surface: **[INTEGRITY-AUDIT.md](./INTEGRITY-AUDIT.md)**. | [ADR-0016](decisions/ADR-0016-session-key-custody.md) | Session-key custody: KMS-wrapped master; rotation via a **global** `key_epoch` + batch re-encryption; per-tenant erasure via a **per-tenant secret** mixed into HKDF (a non-secret marker would erase nothing), not file deletion; dev/CI keep the env-var path unmodified | accepted | 2026-08-12 | Issue #146, follow-up to #98/#143. Decision only — no vendor picked, nothing implemented. Changes derivation, deliberately before a first caller exists | | [ADR-0017](decisions/ADR-0017-pool-vocabulary-rule.md) | Pinned-version vocabulary rule added to the pool allowlist (`src/cache/vocabulary.ts`), additive to `isChromeName`; measured **zero** row-level yield change on the one live bundle — compiler pre-check and recorder tagging are the binding constraints there, not vocabulary | accepted | 2026-08-12 | Issue #126. Multi-version matrix yield is `no_data` — no Docker testbed in this environment | | [ADR-0018](decisions/ADR-0018-session-consent-gate.md) | SC-05 consent moment = a stored consent record checked before every session-establishing run (not onboarding, not a one-time banner); `establishSession` now requires a `SessionAuthorization`, obtainable only via `SessionAuthorization.authorize(baseUrl, consent?)`, which refuses a non-local target with no `ConsentAcknowledgment` | accepted | 2026-08-14 | Issue #102. Gate is enforced by construction, the refusal is enforced by test; persistence, UI, and legal review of the copy are still open | +| [ADR-0019](decisions/ADR-0019-pool-precheck-topology.md) | Compiler `decidePoolEligibility` strips `tenant_scoped` siblings like `buildPoolRow`; live bundle `pool_eligible` moves 1/12 → 7/12 | accepted | 2026-08-21 | Issue #170. Direction invariant unchanged | --- diff --git a/docs/architecture.md b/docs/architecture.md index 0af12a3..71fcb60 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -118,15 +118,18 @@ wired are now wired: `tests/integration/cache-ingest-bundle.test.ts` and `tests/integration/cache-resolve-program.test.ts`. - **The two implementations still disagree on pool eligibility.** On the committed 12-step live - bundle the compiler marks 1 row poolable and the authority marks 7: where a row's whole - locator chain is tainted but it carries `flow_topology`, `buildPoolRow` degrades it to a - `topology_only` pool row — carrying no locator at all — while `decidePoolEligibility` refuses - it outright as `topology_only_degraded`. That direction is legal (a pre-check may be stricter, - never looser) and the dangerous direction is pinned by - `tests/integration/live-bundle-pool.test.ts`. Changing the pre-check changes what every - committed bundle artifact claims about pool eligibility — tracked in - [#170](https://github.com/DevToolie/Paragent/issues/170). + **Pre-check and authority now agree on pool eligibility + ([#170](https://github.com/DevToolie/Paragent/issues/170) / + [ADR-0019](./decisions/ADR-0019-pool-precheck-topology.md)).** The pre-check used to refuse + any chain containing a `tenant_scoped` locator even when a pool-safe `structural` sibling + survived; `buildPoolRow` strips and pools the rest. On the live bundle that understated + shareable rows as 1/12 vs the authority's 7/12. Both now stamp **7/12**. Rather than + maintaining a second copy of the vocabulary, `decidePoolEligibility` now calls the + authority's own `checkLocatorTaint` (`src/cache/taint.ts`) for both the chain and the + assertion target — the same predicate `classifyLocators` and `assertionHasTenantLiteral` + use. The direction invariant (pre-check never looser) remains pinned by + `tests/integration/live-bundle-pool.test.ts`. + 2. ~~**The bundle never reaches the runner.**~~ **Closed by [#62](https://github.com/DevToolie/Paragent/issues/62).** The runner consumes diff --git a/docs/decisions/ADR-0019-pool-precheck-topology.md b/docs/decisions/ADR-0019-pool-precheck-topology.md new file mode 100644 index 0000000..c9f8a44 --- /dev/null +++ b/docs/decisions/ADR-0019-pool-precheck-topology.md @@ -0,0 +1,141 @@ +--- +title: "ADR-0019 — Compiler pool pre-check agrees with write-time authority on locator stripping" +doc_type: adr +status: accepted +owner: B3 +created: 2026-08-21 +updated: 2026-08-21 +confidence: HIGH +supersedes: null +sources_verified: true +--- + +# ADR-0019 — Compiler pool pre-check agrees with write-time authority on locator stripping + +## Status + +accepted + +## Context + +**Triggered by:** [#170](https://github.com/DevToolie/Paragent/issues/170), filed out of +[#169](https://github.com/DevToolie/Paragent/issues/169) once `paragent compile --to-cache` +made the compiler pre-check (`decidePoolEligibility`) and the write-time authority +(`buildPoolRow` / `writeCacheRow`) comparable on real data. + +On the committed 12-step live gate bundle the pre-check marked **1** row poolable and the +authority marked **7**. The gap was one rule, measured against the live trajectory: + +- **Authority** (`src/cache/write.ts::buildPoolRow` / `classifyLocators`): strip + `tenant_scoped` / taint-failing locators, keep the survivors. When a `structural` + sibling remains, pool it. When nothing remains but `flow_topology` exists, pool a + locator-less `topology_only` row. +- **Pre-check** (`src/compiler/pool.ts::decidePoolEligibility`): refused the **whole + chain** on `chain.some(l => l.tenant_scoped)` — so a single tenant-tagged candidate + made the row ineligible even when a pool-safe `structural` survived. + +Nothing tenant-derived escaped either way. The dangerous direction (pre-check looser than +authority) is already pinned by `tests/integration/live-bundle-pool.test.ts`. The problem was +silent drift between two fail-closed implementations of one rule, plus an understated +`pool_eligible` flag on every committed bundle — a §9 / ADR-0014 input about how much of a +task is shareable. + +Three options were on the table in #170: + +1. Teach the pre-check to strip the same way the authority does (agree). +2. Make the authority stricter (drop survivors / topology_only pooling). +3. Delete the pre-check now that the authority runs on a shipped path. + +## Decision + +**Option 1, implemented by calling the authority's own checker.** +`decidePoolEligibility` no longer re-derives the vocabulary; it calls +`checkLocatorTaint` (`src/cache/taint.ts`) — the same predicate `classifyLocators` and +`assertionHasTenantLiteral` use — and mirrors `buildPoolRow`'s branch order: + +1. Assertion first. `expected.template` residue, **the assertion's own + `target.locator` through `checkLocatorTaint`**, then the prose/selector heuristics. +2. Classify the chain with `checkLocatorTaint`. Any untainted survivor ⇒ eligible. A + `topology_only` sentinel is untainted by definition (`createTaintChecker` + short-circuits on it), so a degraded chain lands in the pool set on both sides + without a rule written twice. +3. Otherwise degrade to a locator-less topology row **only if the row will carry + `flow_topology`** — the authority's actual gate. `compileStep` now computes + `flow_topology` before deciding and passes `hasFlowTopology` in. +4. Otherwise refuse, naming the first taint reason. + +### Why call the checker rather than re-filter by strategy + +A first cut of this ADR filtered by strategy (`tenant_scoped` / `text` / `placeholder` / +`topology_only`) instead. It aligned the live bundle at 7/12 and passed +`live-bundle-pool.test.ts` — and still shipped a **looser-than-authority** pre-check, +because a strategy filter cannot see a vocabulary violation. A `testid` of +`dismiss-notice` is not tenant-tagged and is not free text, so the filter kept it; the +authority's `isPoolSafeTestId` rejects it, and the assertion carrying it in +`target.locator` made `writeCacheRow` throw `CacheWriteRejectedError` on the fixture +recording — crashing `record -> compile -> cache`, the one path that has to work. + +The gap predated #170 and was merely unreachable: the blanket `chain.some(tenant_scoped)` +refusal caught those rows a step earlier, so the two implementations agreed by accident. +Removing that refusal (correctly) exposed it. Two copies of one vocabulary is the defect; +one copy with two callers is the fix. + +### Why not option 2 + +Surviving `structural` locators and the `topology_only` degradation are already +canary-tested as the safe path. Narrowing the pool now would change the authority without +evidence those rows are useless cross-tenant, and would need an ADR-0014 amendment of its +own. That is a separate question; this ADR does not decide it. + +### Why not option 3 + +The bundle's `pool_eligible` is still what humans and docs read when they reason about a +committed artifact without running `--to-cache`. Keeping a pre-check that *agrees* with the +authority preserves that prediction; deleting it would leave the field as a historical lie +or force every reader through the write path. The direction invariant (pre-check never +looser) still needs a named function to pin. + +## Consequences + +- Committed live bundle regenerated: `pool_eligible` moves from **1/12** to **7/12**, + matching `--to-cache`. Recompiling it under the final implementation reproduces it + **byte-for-byte**, so the alignment is a property of the rule, not of one run. +- Committed fixture bundle (`traj-example-grafana-login-nav.bundle.json`) regenerated: + step 5 moves `true → false` / `literal_in_assertion`, now matching the authority. It is + **4/6 → 3/6**. That row was never safe to pool; the pre-check simply could not see it. +- Divergence measured at **0** on all three corpora — live bundle, fixture bundle, and a + freshly recorded fixture trajectory (12 / 6 / 6 rows). +- `docs/gate/compiler.md` and related pool docs stop recording the divergence as open. +- `live-bundle-pool.test.ts` now runs its two invariants over **both** committed bundles. + Scoping them to the live bundle is what let the fixture-only divergence through. +- `compileStep` computes `flow_topology` before the pool decision rather than after. +- Canary suite unmodified. + +## Sources + +- `src/compiler/pool.ts`, `src/cache/write.ts` at the commit that lands this ADR +- [#170](https://github.com/DevToolie/Paragent/issues/170) +- [ADR-0014](./ADR-0014-cache-read-path.md) (pool as cross-tenant reuse population) + +## Open questions / what I could not verify + +- Whether a locator-less `topology_only` pool row is *useful* to a different tenant at replay + time — this ADR only aligns the pre-check with the authority that already emits them; it does + not measure cross-tenant hit rate (#67 / hit-rate still `no_data`). +- Whether B5 should refuse URL-path residues in assertions (still open in + `docs/gate/compiler.md`); that disagreement is separate and still safe-direction. +- ~~Whether `decidePoolEligibility` should eventually call the same `checkLocatorTaint` the + authority uses, rather than trusting the compiler's `tenant_scoped` bit.~~ **Resolved + here — it does.** The question was raised as a future risk and turned out to be a present + one: the filtered-chain approach shipped a looser-than-authority pre-check that crashed + the fixture path. A new taint rule now reaches both callers at once. +- `src/compiler/pool.ts` importing from `src/cache/` points the compiler at a downstream + package. It is not a new edge — `src/compiler/cli.ts` has imported `ingestBundle` / + `writeCacheRow` / `JsonlCacheStore` since [#166](https://github.com/DevToolie/Paragent/issues/166) — + and `taint.ts` is a leaf (it imports only `allowlist.ts` and `types.ts`). If the direction + is judged wrong, the shared vocabulary belongs in `src/shared/` on the + [#74](https://github.com/DevToolie/Paragent/issues/74) pattern rather than copied back + into the compiler. Not decided here. +- `PoolIneligibleReason` (compiler) and `TaintReason` (cache) remain separate unions, so the + pre-check maps between them lossily. The **boolean** is what `writeCacheRow` compares, so a + reason mismatch is cosmetic — but the two vocabularies are still a place drift can hide. diff --git a/docs/gate/compiler.md b/docs/gate/compiler.md index 1fe83fc..91bd153 100644 --- a/docs/gate/compiler.md +++ b/docs/gate/compiler.md @@ -225,12 +225,11 @@ Default posture: ineligible until checks pass. | Condition | `pool_eligible` | `pool_ineligible_reason` | | --- | --- | --- | -| Topology-only degradation | `false` | `topology_only_degraded` | -| Any `tenant_scoped` locator | `false` | `tenant_locator_text` | -| `text` / `placeholder` free-text strategies | `false` | `tenant_locator_text` | +| All locators stripped, no `topology_only` fallback | `false` | `tenant_locator_text` | +| Surviving free-text strategies only (`text` / `placeholder`) | `false` | `tenant_locator_text` | | Tenant-looking literal in assertion target/expected | `false` | `literal_in_assertion` | -| Unknown ARIA role (provisional vocab) | `false` | `non_vocab_role` | -| Otherwise clean chrome / templates | `true` | `null` | +| Unknown ARIA role on a surviving locator (provisional vocab) | `false` | `non_vocab_role` | +| Otherwise clean chrome / templates, **or** topology-only degradation | `true` | `null` | Notes and assertion ids are compiler metadata and are **not** scanned for pooling. B5 remains authoritative for write-time allowlist (CONFIDENCE: MED on role @@ -260,22 +259,22 @@ Loosening a privacy rule is B5's call and does not belong in a compiler PR — f here, not fixed. Note the direction: B5 refusing too much is safe; the compiler claiming too much was not. -**Measured, once the authority actually ran (#166).** `paragent compile --to-cache` puts every -row through `writeCacheRow`, so the two implementations can now be compared on real data instead -of in principle. On the committed 12-step live bundle the compiler marks **1** row poolable and -B5 marks **7**. The gap is one rule, in the safe direction: when a row's whole locator chain is -tainted but the row carries `flow_topology`, `buildPoolRow` degrades it to a `topology_only` -pool row — a row carrying no locator at all, only "a click happened here, in `main`, between a -click and a fill" — whereas `decidePoolEligibility` refuses it outright as -`topology_only_degraded`. Nothing tenant-derived escapes either way; the pre-check simply -declines to pool a row B5 is willing to strip and pool. - -That divergence is **not** reconciled here, for the reason the paragraph above gives about the -URL path: it changes what every committed bundle artifact claims about pool eligibility, and the -`pool_eligible` flag in a bundle file is no longer what reaches disk anyway. The number to watch -is the one `--to-cache` prints (`authority pooled N step(s) the compiler pre-check did not`). -Filed as [#170](https://github.com/DevToolie/Paragent/issues/170), which lays out the three ways -it could go and why each is a decision rather than a repair. +**Aligned with the authority (#170 / [ADR-0019](../decisions/ADR-0019-pool-precheck-topology.md)).** +The pre-check used to refuse any chain that *contained* a `tenant_scoped` locator, even when a +pool-safe `structural` sibling survived. `buildPoolRow` strips the tainted entries and pools the +rest — so the bundle said `1/12` poolable while `--to-cache` wrote `7/12`. + +The pre-check now **calls the authority's own `checkLocatorTaint`** (`src/cache/taint.ts`) +instead of re-deriving the vocabulary, and mirrors `buildPoolRow`'s branch order — assertion +first (including the assertion's own `target.locator`), then the chain, then the +`flow_topology` degradation. Re-deriving it was the defect, not just the specific rule that +was wrong: a strategy-based filter cannot see a *vocabulary* violation, so a `testid` like +`dismiss-notice` passed the compiler and failed `isPoolSafeTestId` at the boundary, throwing +`CacheWriteRejectedError` on `record -> compile -> cache`. + +`live-bundle-pool.test.ts` still pins the direction invariant — now over **both** committed +bundles, since scoping it to the live one is what let a fixture-only divergence through. The +live observation is `7/12`, matching the authority; the fixture bundle is `3/6`. ## CLI diff --git a/docs/gate/pool-vocabulary.md b/docs/gate/pool-vocabulary.md index 7fba504..27ca8a9 100644 --- a/docs/gate/pool-vocabulary.md +++ b/docs/gate/pool-vocabulary.md @@ -59,16 +59,20 @@ artifact was committed) by re-running the compiler against its source trajectory ```bash npm run compile -- --in experiments/gate-v1/trajectories/grafana-create-stat-dashboard-from-testdata-9.5.21.json --out -# → rows=12 pool_eligible=1 +# → rows=12 pool_eligible=7 (was 1 before #170 / ADR-0019) ``` | Outcome | Count | | --- | --- | -| `pool_eligible: true` | **1 / 12** (8.3%) | -| `tenant_locator_text` | 10 | +| `pool_eligible: true` | **7 / 12** (58.3%) | +| `tenant_locator_text` | 4 | | `literal_in_assertion` | 1 | -This matches issue #126's quoted numbers exactly. **Verified, not just retyped.** +**Updated 2026-08-21 by [#170](https://github.com/DevToolie/Paragent/issues/170) / +[ADR-0019](../decisions/ADR-0019-pool-precheck-topology.md):** the pre-check now strips +`tenant_scoped` siblings the same way `buildPoolRow` does, so the stamped field matches the +authoritative write path. The historical `1 / 12` figure below is retained only as the +pre-alignment baseline. ### 2. As the authoritative write path (`src/cache/write.ts`) decides today @@ -82,13 +86,12 @@ the pre-PR `src/cache/taint.ts`) and **after**: | `pool_eligible: true` | **7 / 12** (58.3%) | **7 / 12** (58.3%) — unchanged | | `literal_in_assertion` | 5 | 5 — unchanged | -The authoritative number is **7/12, not 1/12**, on the same bundle, before this PR ever touched -anything. The gap between "1" and "7" is not this issue's vocabulary gap — it is the compiler's -pre-check disagreeing with its own authority in the conservative direction (safe, per -`docs/gate/compiler.md`, but currently misleading anyone who reads the artifact's stamped field as -the real number). Six rows (steps 2, 3, 4, 5, 8, 9) already pool today via the existing -`isPoolSafeStructuralPath` allowance (boundary-spec.md rule 2: structural position without quoted -attrs / free text) — none of them needed a name-vocabulary rule. +The authoritative number is **7/12**. Before #170 the compiler pre-check stamped **1/12** on the +same bundle — a conservative disagreement, safe per `docs/gate/compiler.md`, but misleading +anyone who read the artifact's stamped field as the real number. Six rows (steps 2, 3, 4, 5, 8, 9) +pool via the existing `isPoolSafeStructuralPath` allowance (boundary-spec.md rule 2: structural +position without quoted attrs / free text) — none of them needed a name-vocabulary rule. Since +ADR-0019 the stamped field and the write path agree at 7/12. **This PR's vocabulary rule changes the row-level count on this bundle by exactly zero**, and that is reported here rather than hidden. Two independent, already-out-of-scope reasons: diff --git a/src/compiler/compile.ts b/src/compiler/compile.ts index 0c84c83..cb62ccf 100644 --- a/src/compiler/compile.ts +++ b/src/compiler/compile.ts @@ -68,12 +68,6 @@ export function compileStep( }, assertionOptions, ); - const pool = decidePoolEligibility({ - chain: action.locator_fallback_chain, - assertion, - topologyOnly, - }); - const steps = trajectory.steps; const idx = step.step_index; const prev = steps.find((s) => s.step_index === idx - 1); @@ -84,6 +78,17 @@ export function compileStep( if (next) flow_topology.next_action_type = next.action.type; const landmark = landmarkHint(step); if (landmark) flow_topology.landmark = landmark; + // Computed before the pool decision, not after: `buildPoolRow` degrades an + // all-tainted chain to a `topology_only` pool row *only* when the row carries + // `flow_topology`, so the pre-check has to know whether this row will (#170). + const hasFlowTopology = Object.keys(flow_topology).length > 0; + + const pool = decidePoolEligibility({ + chain: action.locator_fallback_chain, + assertion, + topologyOnly, + hasFlowTopology, + }); const row: CacheRow = { schema_version: SCHEMA_VERSION, @@ -110,7 +115,7 @@ export function compileStep( pool_ineligible_reason: pool.pool_ineligible_reason, }; - if (Object.keys(flow_topology).length > 0) { + if (hasFlowTopology) { row.flow_topology = flow_topology; } diff --git a/src/compiler/pool.ts b/src/compiler/pool.ts index 4fb39ed..c1da721 100644 --- a/src/compiler/pool.ts +++ b/src/compiler/pool.ts @@ -1,3 +1,5 @@ +import { checkLocatorTaint } from "../cache/taint.js"; +import type { TaintReason } from "../cache/types.js"; import { collectStringEntries, isChromeLabel, @@ -17,122 +19,150 @@ export interface PoolDecision { } /** - * Fail-closed pool eligibility. Scans locator chain + assertion target/expected - * only (not notes / ids). B5 is authoritative; this is the compiler pre-check. + * The authority's taint vocabulary, expressed in the compiler's reason union. + * + * `TaintReason` (src/cache/types.ts) is finer-grained than the schema-visible + * `PoolIneligibleReason`, so the mapping is lossy on purpose — the boolean is + * what `writeCacheRow` compares, and the reason is for a human reading the row. */ -export function decidePoolEligibility(args: { - chain: CompiledLocator[]; - assertion: Assertion; - topologyOnly: boolean; -}): PoolDecision { - const { chain, assertion, topologyOnly } = args; - - if (topologyOnly) { - return { - pool_eligible: false, - pool_ineligible_reason: "topology_only_degraded", - }; - } - - if (chain.some((l) => l.tenant_scoped === true)) { - return { - pool_eligible: false, - pool_ineligible_reason: "tenant_locator_text", - }; - } - - if ( - chain.some( - (l) => - (l.strategy === "text" || l.strategy === "placeholder") && - (l.text !== undefined || l.name !== undefined), - ) - ) { - return { - pool_eligible: false, - pool_ineligible_reason: "tenant_locator_text", - }; - } +const TAINT_REASON_TO_POOL_REASON: Record = { + caller_marked_tenant: "tenant_locator_text", + free_text: "tenant_locator_text", + aria_or_name_tenant: "tenant_locator_text", + role_text_tenant: "tenant_locator_text", + non_vocab_role: "non_vocab_role", + non_vocab_testid: "tainted_attribute", + non_vocab_attr: "tainted_attribute", + structural_free_text: "tainted_attribute", +}; - // Mirror of B5's `assertionHasTenantLiteral` (src/cache/write.ts): anything - // left in `expected.template` after the holes are removed is treated as a - // literal. This pre-check must never be *more permissive* than the authority - // — `writeCacheRow` throws `CacheWriteRejectedError` when a caller claims - // pool_eligible and B5 disagrees, so a permissive pre-check is a crash, not a - // leak, but a crash in the one path that has to work. - // - // Found by routing the live bundle through the write path (issue #25): the - // compiler called every `url-matches` row poolable and B5 refused all four, - // because a URL template's residue is its *path*. Whether refusing a path is - // right is B5's call and a separate question — see docs/gate/compiler.md. The - // compiler's job is to agree with it. +/** + * Mirror of B5's `assertionHasTenantLiteral` (src/cache/write.ts). + * + * Three checks, and all three have to be here because the authority runs all + * three. Anything left in `expected.template` after the holes are removed is + * treated as a literal. + * + * Found by routing the live bundle through the write path (issue #25): the + * compiler called every `url-matches` row poolable and B5 refused all four, + * because a URL template's residue is its *path*. Whether refusing a path is + * right is B5's call and a separate question — see docs/gate/compiler.md. The + * compiler's job is to agree with it. + */ +function assertionLiteralReason(assertion: Assertion): PoolIneligibleReason | null { const expectedTemplate = assertion.expected?.template; if (expectedTemplate !== undefined) { const residue = expectedTemplate .replace(/\{[a-zA-Z_][a-zA-Z0-9_]*\}/g, "") .trim(); if (residue.length > 0 && !isChromeLabel(residue) && !isChromeLabel(expectedTemplate)) { - return { - pool_eligible: false, - pool_ineligible_reason: "literal_in_assertion", - }; + return "literal_in_assertion"; } } - const payload = { - target: assertion.target, - expected: assertion.expected, - }; + // The assertion's own target locator goes through the *authority's* checker, + // not the prose heuristics below (#170). `assertionHasTenantLiteral` runs + // `checkLocatorTaint(assertion.target.locator)`, which is a vocabulary + // allowlist — and a value like `testid: "dismiss-notice"` is a plain kebab + // string that no prose rule flags but `isPoolSafeTestId` rejects outright. + // + // This gap predates #170. It was unreachable while the pre-check refused any + // chain containing a `tenant_scoped` locator, because the fixture rows that + // hit it were already refused a step earlier for a different reason. Removing + // that blanket refusal (correctly) exposed it, and the pre-check went + // *looser* than the authority — which `writeCacheRow` answers with a + // `CacheWriteRejectedError`, crashing record -> compile -> cache. + const targetLocator = assertion.target?.locator; + if (targetLocator && checkLocatorTaint(targetLocator as CompiledLocator).tainted) { + return "literal_in_assertion"; + } + // Key-aware on purpose: an assertion target can embed the same selector the // locator chain carries (`structural_path`, `count_scope`), and judging a CSS // path by the prose rule marked a live row `literal_in_assertion` for owning // a long DOM path — see looksLikeTenantSelector. + const payload = { target: assertion.target, expected: assertion.expected }; for (const { key, value } of collectStringEntries(payload)) { const tainted = SELECTOR_KEYS.has(key) ? looksLikeTenantSelector(value) : looksLikeTenantLiteral(value); - if (tainted) { - return { - pool_eligible: false, - pool_ineligible_reason: "literal_in_assertion", - }; - } + if (tainted) return "literal_in_assertion"; } - const KNOWN_ROLES = new Set([ - "button", - "textbox", - "checkbox", - "radio", - "link", - "menuitem", - "tab", - "option", - "combobox", - "searchbox", - "main", - "form", - "navigation", - "banner", - "contentinfo", - "complementary", - "heading", - "img", - "list", - "listitem", - "dialog", - "alertdialog", - "alert", - "status", - ]); - for (const loc of chain) { - if (loc.strategy === "role_name" && loc.role && !KNOWN_ROLES.has(loc.role)) { - return { - pool_eligible: false, - pool_ineligible_reason: "non_vocab_role", - }; - } + return null; +} + +/** + * Fail-closed pool eligibility. Scans locator chain + assertion target/expected + * only (not notes / ids). B5 is authoritative; this is the compiler pre-check. + * + * **This pre-check must never be more permissive than the authority.** + * `writeCacheRow` throws `CacheWriteRejectedError` when a caller claims + * `pool_eligible` and B5 disagrees, so a permissive pre-check is a crash, not a + * leak — but a crash in the one path that has to work. + * + * The way it stays aligned (#170 / ADR-0019) is by calling the authority's own + * `checkLocatorTaint` rather than re-deriving the vocabulary here. Before that, + * three separate approximations had drifted: a strategy-based strip that missed + * non-vocabulary `testid`s, an assertion check that skipped the target locator, + * and a topology branch keyed off the wrong field. Branch order below mirrors + * `buildPoolRow` so the reported reason matches too, not just the boolean. + */ +export function decidePoolEligibility(args: { + chain: CompiledLocator[]; + assertion: Assertion; + /** + * Retained for the caller's benefit, deliberately unread. + * + * `buildLocatorFallbackChain` sets this `true` only in the branch that also + * appends the `topology_only` sentinel to `chain` (src/compiler/locators.ts), + * so it is exactly equivalent to a property of `chain` — and the chain is + * what the authority sees. Branching on the flag instead is how the first cut + * of #170 ended up asking a different question than `buildPoolRow` does. + */ + topologyOnly: boolean; + /** + * Whether the row will carry `flow_topology`. The authority degrades an + * all-tainted chain to a locator-less `topology_only` pool row only when this + * is present (`buildPoolRow`, src/cache/write.ts), so the pre-check cannot + * assume it. `compileStep` computes `flow_topology` before deciding and + * passes the answer in. + */ + hasFlowTopology?: boolean; +}): PoolDecision { + const { chain, assertion, hasFlowTopology = false } = args; + + // 1. Assertion first — same order as `buildPoolRow`, so a row that fails both + // the assertion check and the locator check reports the assertion reason. + const assertionReason = assertionLiteralReason(assertion); + if (assertionReason) { + return { pool_eligible: false, pool_ineligible_reason: assertionReason }; } - return { pool_eligible: true, pool_ineligible_reason: null }; + // 2. Classify the chain with the authority's predicate. A `topology_only` + // sentinel is untainted by definition (`createTaintChecker` short-circuits + // on it), so a chain the compiler degraded lands in `poolSafe` here and in + // `classifyLocators` there — the two agree by construction rather than by + // a rule written twice. + const taints = chain.map((locator) => checkLocatorTaint(locator)); + if (taints.some((t) => !t.tainted)) { + return { pool_eligible: true, pool_ineligible_reason: null }; + } + + // 3. Nothing pool-safe survived. The authority still pools when it can + // degrade to a locator-less topology row. + if (hasFlowTopology) { + return { pool_eligible: true, pool_ineligible_reason: null }; + } + + // 4. Refuse, naming the first taint the way the authority does. + const firstReason = taints.flatMap((t) => t.reasons)[0]; + return { + pool_eligible: false, + pool_ineligible_reason: firstReason + ? TAINT_REASON_TO_POOL_REASON[firstReason] + : chain.length === 0 + ? "topology_only_degraded" + : "other", + }; } diff --git a/tests/integration/live-bundle-pool.test.ts b/tests/integration/live-bundle-pool.test.ts index 0d2a04a..0dcf0fe 100644 --- a/tests/integration/live-bundle-pool.test.ts +++ b/tests/integration/live-bundle-pool.test.ts @@ -27,6 +27,25 @@ const BUNDLE = path.join( "artifacts/compiled/traj-gate-live-create-stat-dashboard-from-testdata-9.5.21.bundle.json", ); +/** + * The fixture bundle is checked by the same invariants (#170). + * + * It used to be exempt, and that is how the pre-check went looser than B5 + * without this file noticing: the divergent row — a non-vocabulary `testid` in + * the assertion target — does not occur in the live gate task, so the live + * bundle stayed green while `pipeline.test.ts` crashed on the fixture. An + * invariant that only runs against one corpus is an invariant with a hole. + */ +const EXAMPLE_BUNDLE = path.join( + process.cwd(), + "artifacts/compiled/traj-example-grafana-login-nav.bundle.json", +); + +const ALL_BUNDLES: readonly [string, string][] = [ + ["live gate task", BUNDLE], + ["fixture login/nav", EXAMPLE_BUNDLE], +]; + interface Bundle { rows: (CacheRow & { pool_eligible: boolean })[]; } @@ -46,42 +65,53 @@ function toCandidate(row: Bundle["rows"][number]): CacheRowCandidate { } describe("live compiled bundle through the B5 write path", () => { - it("never claims pool eligibility the cache would refuse", async () => { - const bundle = JSON.parse(await readFile(BUNDLE, "utf8")) as Bundle; - expect(bundle.rows).toHaveLength(12); + it.each(ALL_BUNDLES)( + "never claims pool eligibility the cache would refuse (%s)", + async (_label, bundlePath) => { + const bundle = JSON.parse(await readFile(bundlePath, "utf8")) as Bundle; + expect(bundle.rows.length).toBeGreaterThan(0); - for (const row of bundle.rows) { - // Throws CacheWriteRejectedError on a claim B5 will not honour. - const { pool } = writeCacheRowPair(toCandidate(row)); - if (row.pool_eligible) { - expect( - pool.pool_eligible, - `row ${row.step_index}: compiler said poolable, B5 said ${pool.pool_ineligible_reason}`, - ).toBe(true); + for (const row of bundle.rows) { + // Throws CacheWriteRejectedError on a claim B5 will not honour. + const { pool } = writeCacheRowPair(toCandidate(row)); + if (row.pool_eligible) { + expect( + pool.pool_eligible, + `row ${row.step_index}: compiler said poolable, B5 said ${pool.pool_ineligible_reason}`, + ).toBe(true); + } } - } - }); + }, + ); - it("lets no tenant-scoped or free-text locator into a pool row", async () => { - const bundle = JSON.parse(await readFile(BUNDLE, "utf8")) as Bundle; + it.each(ALL_BUNDLES)( + "lets no tenant-scoped or free-text locator into a pool row (%s)", + async (_label, bundlePath) => { + const bundle = JSON.parse(await readFile(bundlePath, "utf8")) as Bundle; - for (const row of bundle.rows) { - const { pool, tenant } = writeCacheRowPair(toCandidate(row)); - if (pool.pool_eligible) { - for (const loc of pool.compiled_action.locator_fallback_chain) { - expect(loc.tenant_scoped).not.toBe(true); - expect(["text", "placeholder"]).not.toContain(loc.strategy); + for (const row of bundle.rows) { + const { pool, tenant } = writeCacheRowPair(toCandidate(row)); + if (pool.pool_eligible) { + for (const loc of pool.compiled_action.locator_fallback_chain) { + expect(loc.tenant_scoped).not.toBe(true); + expect(["text", "placeholder"]).not.toContain(loc.strategy); + } } + // The tenant row keeps everything: the split is what makes the pool row + // safe to share, and a tenant row that lost candidates would replay worse + // than the trajectory it came from. + expect( + tenant.compiled_action.locator_fallback_chain.length, + ).toBeGreaterThanOrEqual( + row.compiled_action.locator_fallback_chain.length, + ); } - // The tenant row keeps everything: the split is what makes the pool row - // safe to share, and a tenant row that lost candidates would replay worse - // than the trajectory it came from. - expect( - tenant.compiled_action.locator_fallback_chain.length, - ).toBeGreaterThanOrEqual( - row.compiled_action.locator_fallback_chain.length, - ); - } + }, + ); + + it("holds the live bundle at 12 rows", async () => { + const bundle = JSON.parse(await readFile(BUNDLE, "utf8")) as Bundle; + expect(bundle.rows).toHaveLength(12); }); it("keeps the committed bundle schema-valid", async () => { @@ -105,7 +135,7 @@ describe("live compiled bundle through the B5 write path", () => { const poolable = bundle.rows.filter((r) => r.pool_eligible); expect(strong).toHaveLength(6); - expect(poolable).toHaveLength(1); + expect(poolable).toHaveLength(7); expect( bundle.rows.every((r) => r.assertion.notes && r.assertion.notes.length > 0), ).toBe(true); diff --git a/tests/unit/compiler.test.ts b/tests/unit/compiler.test.ts index 86937fe..1b8f4f6 100644 --- a/tests/unit/compiler.test.ts +++ b/tests/unit/compiler.test.ts @@ -198,6 +198,120 @@ describe("pool pre-check never outruns the B5 authority", () => { }); expect(decision.pool_eligible).toBe(true); }); + + it("agrees with B5 on topology_only degradation (#170)", () => { + // buildPoolRow pools a locator-less topology row when the chain is all + // tainted but flow_topology exists. The pre-check used to refuse those + // as topology_only_degraded, understating pool_eligible on every bundle. + const decision = decidePoolEligibility({ + chain: [ + { + strategy: "role_name", + role: "button", + name: "Save dashboard", + tenant_scoped: true, + }, + { strategy: "topology_only", tenant_scoped: false }, + ], + topologyOnly: true, + assertion: { + schema_version: "1.0.0", + assertion_id: "a", + type: "element-visible", + strength: "weak", + timeout_ms: 5000, + failure_classification: "assertion_failed", + }, + }); + expect(decision.pool_eligible).toBe(true); + expect(decision.pool_ineligible_reason).toBeNull(); + }); + + it("strips tenant_scoped siblings and pools the surviving structural (#170)", () => { + // The live gate gap was this shape: a mixed chain where some candidates are + // tenant_scoped and a structural survives. buildPoolRow keeps structural; + // the old pre-check refused the whole row on `.some(tenant_scoped)`. + const decision = decidePoolEligibility({ + chain: [ + { + strategy: "role_name", + role: "button", + name: "Save dashboard", + tenant_scoped: true, + }, + { + strategy: "structural", + structural_path: "main > button", + tenant_scoped: false, + }, + ], + topologyOnly: false, + assertion: { + schema_version: "1.0.0", + assertion_id: "a", + type: "element-visible", + strength: "weak", + timeout_ms: 5000, + failure_classification: "assertion_failed", + }, + }); + expect(decision.pool_eligible).toBe(true); + expect(decision.pool_ineligible_reason).toBeNull(); + }); + + it("refuses a non-vocabulary testid in the assertion target (#170)", () => { + // The shape that crashed record -> compile -> cache. B5's + // assertionHasTenantLiteral runs checkLocatorTaint over + // assertion.target.locator, and `dismiss-notice` is not in the testid + // vocabulary — but it is a plain kebab string that no prose heuristic + // flags. Stripping the tenant_scoped role_name (correct, #170) made this + // row reachable, and the pre-check called it poolable while B5 refused it. + const decision = decidePoolEligibility({ + chain: [ + { strategy: "role_name", role: "button", name: "Dismiss", tenant_scoped: true }, + { strategy: "testid", testid: "dismiss-notice", tenant_scoped: false }, + { strategy: "structural", structural_path: "main > button", tenant_scoped: false }, + ], + topologyOnly: false, + hasFlowTopology: true, + assertion: { + schema_version: "1.0.0", + assertion_id: "a", + type: "element-visible", + strength: "strong", + target: { locator: { strategy: "testid", testid: "dismiss-notice" } }, + expected: { visible: false }, + timeout_ms: 5000, + failure_classification: "assertion_failed", + }, + }); + expect(decision.pool_eligible).toBe(false); + expect(decision.pool_ineligible_reason).toBe("literal_in_assertion"); + }); + + it("does not pool an all-tainted chain when the row has no flow_topology (#170)", () => { + // buildPoolRow degrades to a locator-less topology row only when + // candidate.flow_topology is present; without it the authority refuses. + const chainAllTainted = [ + { strategy: "role_name" as const, role: "button", name: "Save dashboard", tenant_scoped: true }, + ]; + const assertion = { + schema_version: "1.0.0", + assertion_id: "a", + type: "element-visible", + strength: "weak", + timeout_ms: 5000, + failure_classification: "assertion_failed", + } as const; + expect( + decidePoolEligibility({ chain: chainAllTainted, assertion, topologyOnly: true, hasFlowTopology: false }) + .pool_eligible, + ).toBe(false); + expect( + decidePoolEligibility({ chain: chainAllTainted, assertion, topologyOnly: true, hasFlowTopology: true }) + .pool_eligible, + ).toBe(true); + }); }); describe("click assertion target", () => {