Skip to content

[finding] check-org-identifier excludes test files wholesale, so a fixture that CONSTRUCTS the removed session dialect reads the same as one asserting its absence #9809

Closed

Description

@os-steve

Found while implementing #9691 (the receiver-name anchor). Reported, not fixed — #9691's scope is the receiver anchor, and this is a separate axis of the same gate. Unassigned. Not a claim.

Measured on 11b779e0f plus the #9691 branch.

The mechanism

scripts/check-org-identifier.mjs drops every test file before scanning:

const TEST_FILE = /(\.(test|spec)\.[cm]?[jt]sx?$)|((^|\/)__tests__\/)/;

with the header's reason:

Test/spec files are EXCLUDED: they legitimately reference the removed token to assert its ABSENCE (expect(session.tenantId).toBeUndefined()), and are not reference bodies an author copies a hook from.

That reason is correct about one population and blind to another. A test can name the removed alias in two structurally different ways:

  • asserting its absence — the case the exclusion is written for, and legitimate;
  • CONSTRUCTING it as an input — handing a hook a session object literal that spells tenantId, a dialect HookContextSchema strips and ObjectQLEngine.buildSession never emits.

The second is not a reference body an author copies, so the header's stated reason does not cover it — but it is the shape that can hold a production defect green, because the fixture supplies the very key production cannot.

The instance that made this concrete

packages/services/service-storage/src/attachment-access-hooks.test.ts covered the session-fallback path of callerContext() like this (pre-#9691):

session: { userId: 'u1', tenantId: 'org_1', positions: ['p1'] },
...
expect(canEdit.mock.calls[0][2]).toEqual({ userId: 'u1', tenantId: 'org_1', positions: ['p1'] });

The production code under it read s.tenantId off the session — always undefined in reality, so the attachment kit forwarded an envelope with no organization to ISharingService.canEdit. The fixture passed for exactly as long as the code was wrong, and could only have failed if the code became right. It went red the moment #9691 fixed the source.

So the fixture was not merely uncovered by the gate; it was the mechanism keeping a live defect green, in a file the gate is written to skip.

Census

Across the 2435 tracked test files under examples/ apps/ packages/, fixtures that build a session: object literal carrying a tenantId key:

packages/spec/src/data/hook.test.ts:619                              deliberate #3290 absence pin
packages/plugins/plugin-audit/src/audit-writers.test.ts:1666         deliberate absence pin (PR #9692)
packages/plugins/plugin-audit/src/comment-access-hooks.test.ts       deliberate absence pin (#9691)
packages/services/service-storage/src/attachment-access-hooks.test.ts deliberate absence pin (#9691)

4 sites, 0 of them wrong today — the two #9691 ones were rewritten in that PR, and the other two were always deliberate. So this is LATENT, not live. It is filed because the population it is blind to is the population that produced the only measured instance, and because the exclusion's stated reason is narrower than the exclusion itself.

What a fix would have to decide (not decided here)

The distinguishing signal is available structurally: an absence pin puts the removed key on the ASSERTION side (toBeUndefined, not.toHaveProperty), a phantom-green fixture puts it on the INPUT side (a session: property of a context literal handed to the code under test). Whether that distinction is worth a rule, a lint, or nothing at all is a judgement, and the honest options differ in cost:

  1. leave it, and narrow the header's stated reason to match what the exclusion actually does;
  2. scan test files for the INPUT shape only (a session object literal carrying tenantId), which is a new recognizer with its own blind spots;
  3. treat it as an instance of Seven gates report "clean" when they mean "I saw nothing I understood" — make unrecognised a verdict distinct from pass #9747 and let the family-level answer cover it.

My weak preference is (1) plus recording the measurement, on the grounds that 4 sites with 0 wrong does not obviously earn a new recognizer, and #9747 is already the place where "my recognizer is narrower than the code" is being decided as a family.

Related

Activity

  1. claude commented on Aug 19, 2026

    @claude
    Contributor

    Claimed by the domain:devx PM seat — dispatching to an os-dev subagent now.

    • Session ID: session_01XqDQYVU5smx29ts9pAErja
    • Branch: claude/issue-9809-org-identifier-test-exclusion
    • Container & model: S, mode:subagent, model: opus (SKILL default tier — this card neither touches .claude/skills/pm-dispatch/** nor changes any gate's accept/reject behaviour under the scope ruled below)

    State: pm:queue → pm:dispatched, assigned os-steve.


    Generated by Claude Code

  2. os-steve commented on Aug 19, 2026

    @os-steve
    CollaboratorAuthor
    {
      "issue": 9809,
      "status": "done",
      "branch": "claude/issue-9809-org-identifier-test-exclusion",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/9874",
      "premise_still_valid": true,
      "summary": "Built ruling 1 and only ruling 1: narrowed the header's stated reason for the wholesale test/spec exclusion, and recorded the measurement in the script. No recognizer added, so the gate's accept/reject behaviour is unchanged (same corpus, same verdict, 2058 files before and after). Measuring turned up a correction the card did not have: the exclusion is NOT what hides the construction shape. Both rules grade a `.tenantId` READ off a session-valued receiver, and a `session: { tenantId: … }` object literal is a construction, not a read, so it scores 0 even under a NON-test filename with the exclusion bypassed — including the verbatim pre-#9691 phantom-green that held a live defect green. Deleting the exclusion would surface zero construction sites; option (2) is therefore a new recognizer on a new axis, not a loosening of this filter, which strengthens rather than undermines the ruling. Both copies of the overclaim were in the gate script itself and both are narrowed; the blind spot is pinned in the existing --self-test.",
      "tests": "All on final commit fab54d793 (union re-run after the last commit). `pnpm check:org-identifier` -> '✓ check-org-identifier self-test: 31 cases pass.' + 'OK (2058 author-facing source file(s), 13 session binding(s) resolved, no removed session.tenantId alias).' (baseline before the change: 28 cases, same 2058/13 — verdict unchanged). `pnpm check:cross-package-test-inputs` -> 'All 33 self-test cases passed. OK: 12 package(s) read outside themselves, all declared.' `node scripts/check-nul-bytes.mjs` -> 'OK (scanned 6282 text file(s) ... no raw ASCII control bytes)' plus a manual grep -naP control-char sweep of the edited file: none. Gate family derived from the changed path via `node scripts/pm/dispatch-gates.mjs scripts/check-org-identifier.mjs`, which named check:org-identifier and check:cross-package-test-inputs; check:nul-bytes added because the diff is all prose. REVERSE VERIFICATION, run from the committed state and restored with `git checkout <branch> -- <path>`: flipping all three new expectations (absence-pin 1->0, both construction cases 0->1) turned the harness red and named each case individually — direction as predicted, none passes vacuously. No ablation applicable (no dist/-resolved code involved).",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #9872: roughly ten comments across production code, tests and this gate's own header date the #3290 `session.tenantId` removal to 'v11', but it shipped in v16 — content/docs/releases/v16.mdx is the only release page mentioning #3290, its 16.0.0 upgrade checklist lists the rename, and there is no v11.mdx at all (pages run v9, v12-v17). skills/objectstack-data/references/data-hooks.md is the single site that has it right. Unassigned, `finding` label, no pm:queue."
      ]
    }

    PM's explicit asks

    H1 — census re-measured on 83f8267f5 (current main), not the card's 11b779e0f+#9691: 4 sites, 0 wrong — reproduces. Same four files the card names, all still deliberate pins. Population drifted: 2449 excluded test/spec files (card: 2435), 2058 scanned. Also measured, which the card did not: 0 such literals in the scanned non-test population. No live phantom-green, so ruling 3's stop-and-report condition was not triggered. Reproduce with rg -l --multiline --multiline-dotall 'session\s*:\s*\{[^{}]*\btenantId\b' -g '*.test.ts' -g '*.spec.ts' examples apps packages (reports 5 matches across those 4 files; hook.test.ts carries a second whose tenantId sits in a comment inside the literal body).

    H2 — two copies, both in the gate script, both narrowed:

    • scripts/check-org-identifier.mjs — the header bullet the card quotes.
    • scripts/check-org-identifier.mjs — a terser restatement immediately above the TEST_FILE regex ("Tests assert the alias is GONE, so they reference the token on purpose"). This one was a genuine second copy; narrowing only the header would have left it standing.

    Checked and clean, no edit made:

    • .github/workflows/lint.yml — states the exclusion neutrally ("tests, comments, skills/ and docs/ are excluded") with no reason attached, so it carries no overclaim.
    • The gate's failure message and --help/usage lines say nothing about tests.
    • Sibling gates with a TEST_FILE regex (check-test-source-alias, check-type-check-coverage) exclude tests for unrelated reasons; neither copied this justification.

    Governed surfaces: nothing to report. AGENTS.md and .claude/** contain no mention of this gate, session.tenantId, or the exclusion — grepped both. So no governed-surface copy exists and #9840's shape did not arise.

    H3 — falsified as stated, and this is the most useful thing measured. The card's discriminator (absence pins put the key on the ASSERTION side, phantom-greens on the INPUT side) does not survive contact: all four deliberate pins put the removed key on the INPUT side. Constructing the dialect on purpose is how you pin that it gets stripped. audit-writers.test.ts never names the key on the assertion side at all — it asserts a downstream organization_id is null — so under the card's rule it would read as a phantom-green. The real separator is whether the fixture asserts the key's fate (absent/inert) or echoes its value back as expected output; and even that is not a text rule, because attachment-access-hooks.test.ts legitimately asserts toEqual({ …, tenantId: 'org_1', … }) one test earlier — tenantId on the way OUT is ExecutionContext's driver-layer name for the same value, byte-identical to what a phantom-green writes. All of this is now written into the header.

    H4 — built, because the harness already existed. --self-test was already there (package.json runs --self-test && the scan). Added three cases, 28 → 31: the absence-pin shape asserts 1 offender (a finding but for the filter), and two construction shapes assert 0 (invisible to both rules). They run under a non-test filename, so they state what the rules do independently of TEST_FILE. Labelled in-place so a future author knows flipping them to 1 is the contract moving on purpose, not a regression. Nothing was invented — no new harness.

    Changeset: none added (.changeset/** is inside the #9465 fence). The PR is comment prose plus self-test cases in a CI script and publishes nothing, so the repo's real skip-changeset label was applied to #9874 and read back to confirm.

    #9747: cross-referenced in prose only. Nothing added to it, and it is not named as the fix.

    PR #9874 is draft; auto-merge not enabled and I did not flip it out of draft.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions