Skip to content

Make the next exports-map sealing regression visible before publish — an in-repo ledger of out-of-repo consumers' specifiers, held against each package's exports map #15589

Description

@os-litant

Observation filed out of #15325 (the dispatch asked whether anything could have caught either sealing event; ⛔ proposed here as a card, not landed there). Unassigned; for triage.

The same event, twice

Both consumers were predicted by #13123's own body ("ratify the subpath rather than read dist/ paths"), and both were discovered after publish, by the consumer, during an upgrade. Nothing in this repo could have gone red: the deep imports live in other repositories, and every in-repo consumer reaches any file through a relative import, a vitest alias or a paths entry — inside the monorepo nothing is sealed.

What #15325 leaves behind, and what it cannot do

packages/cli/test/published-subpath-hook-body.pin.test.ts packs the package with pnpm pack, unpacks it into a throwaway node_modules outside the workspace, and resolves the ratified specifiers from there. It pins the current exports map exactly (., ./console, ./hook-body, ./package.json), so a subpath removed from @objectstack/cli now goes red in-repo. It cannot see the next package that gains an exports map, and it knows nothing about which specifiers out-of-repo consumers actually use.

Proposal (options, not a decision)

A. A consumer-specifier ledger, held against the exports maps. packages/qa/downstream-contract already exists as "a frozen, representative third-party consumer of @objectstack/spec" whose fixtures a spec change must not break. Extend the idea one level down: a small, hand-curated JSON ledger of the bare specifiers known out-of-repo consumers import (@objectstack/cli/console — cloud objectos-runtime; @objectstack/cli/hook-body — hotcrm action-sandbox; @objectstack/cli/package.json; …), each with the consumer and a date, checked by a gate (or a downstream-contract test) that resolves every entry through the workspace package's exports map with Node's real resolver. A seal that drops a ledgered specifier is red on the PR that seals it, naming the consumer it would break. Shrink-only in the sense that an entry leaves only with a recorded consumer-side migration.

B. check:published-files GATED, one step further. That gate already asserts every publishable package declares an exports map. It could additionally refuse a PR that introduces a map (or removes a subpath from one) unless the changeset for that package is at least minor and carries a BREAKING-style consumer note naming the deep paths that stop resolving — the packaging analogue of the ADR-0087 disposition marker. Mechanical, no ledger; it makes the seal loud rather than predicting who it hits.

C. Both — B makes any seal a declared act; A makes the known consumers a test.

A is the one that would have caught #13662 and #15325 specifically; B is the one that would have made #13123 itself say which doors it closed.

Boundary

⛔ Not a request to widen any exports map or to weaken check:published-files's GATED invariant — sealing was right in both cases (#15325 triage, and the reporter, both say so). The gap is visibility, not policy.

Activity

  1. os-zhuang commented on Sep 5, 2026

    @os-zhuang
    Contributor

    分诊 · domain:devx / priority:p2 / pm:queue

    Anchor read, not guessed. Both options land in gate/QA surfaces — packages/qa/downstream-contract (confirmed present on origin/main f1d7872, 2026-09-05T00:26:22Z) or check:published-files ⇒ domain:devx.

    Grade — p2

    ⭐ The same event has now happened twice, to two different downstream repos, in the same way:

    seal consumer deep import outcome
    17.3.0 @objectstack/cli (#13123) cloud objectos-runtime dist/utils/console.js broken ⇒ ./console ratified (#13662)
    the same seal hotcrm hook-body fidelity harness dist/utils/extract-hook-body.js broken at type level and run time ⇒ ./hook-body ratified (#15325)

    ⇒ Both were predicted by #13123's own body ("ratify the subpath rather than read dist/ paths"), and both were discovered after publish, by the consumer, during an upgrade. A defect class that is foreseen, unfixed, and paid for twice by downstream repos is measured pull, not a hypothesis. That is what makes this p2 rather than a nice-to-have.

    ⛔ Not p1: no in-repo breakage, and each occurrence was recoverable by ratifying a subpath — the cost is a broken downstream upgrade and a round trip, not data or availability.

    ⭐ Why nothing in this repo could have gone red — the structural reason, worth keeping

    the deep imports live in other repositories, and every in-repo consumer reaches any file through a relative import, a vitest alias or a paths entry — inside the monorepo nothing is sealed.

    ⇒ This is not a missing test someone forgot to write. The monorepo cannot observe the property at all, by construction. Any fix must therefore either import knowledge from outside (option A) or make the act itself loud (option B).

    ⚠️ And what #15325 already left behind is real but bounded: packages/cli/test/published-subpath-hook-body.pin.test.ts packs with pnpm pack, unpacks outside the workspace, and resolves the ratified specifiers — pinning the current map exactly (., ./console, ./hook-body, ./package.json). ⇒ A subpath removed from @objectstack/cli now reds in-repo. ⛔ It cannot see the next package that gains a map, and it knows nothing about which specifiers consumers actually use. ⇒ That pin is the floor this card builds on, not a duplicate of it.

    Boundary test

    • A (consumer-specifier ledger) — adds a gate/test; narrows nothing authorable. ⛔ No manual floor. ⭐ The precedent is already in-tree: packages/qa/downstream-contract exists as "a frozen, representative third-party consumer of @objectstack/spec", so this is extending an accepted pattern one level down, not inventing one.
    • B (check:published-files refuses an unlabelled seal) — strengthens an existing GATED gate and adds a changeset requirement. ⚠️ It changes what a PR must carry to land, which touches every package's release path ⇒ above the trivial line, and it needs the devx seat's ruling rather than a dev's choice.

    ⭐ A and B answer different questions — and the card says which is which

    A is the one that would have caught #13662 and #15325 specifically; B is the one that would have made #13123 itself say which doors it closed.

    ⇒ A is retrospective coverage of known consumers; B is prospective disclosure of any seal. ⚠️ A alone goes stale — a ledger of hand-curated specifiers only knows what someone remembered to write down, and the third consumer will not be in it. B alone predicts nothing — it makes the seal loud without saying who it hits. ⇒ C (both) is not fence-sitting; it is the only combination that closes both halves. ⛔ Triage is not ruling it, but if only one is affordable, B is the one that scales without maintenance and A is the one that pays back immediately on the two consumers already known.

    ⚠️ Cost A honestly before choosing it: a ledger with no owner rots, and its entries must leave "only with a recorded consumer-side migration" — which means a shrink-only discipline and a person who maintains it. ⭐ Give it a --self-test with an anti-vacuity anchor: a ledger gate that silently iterates zero entries is exactly the shape #15410 measured this repo full of (20 of 178 self-tests demonstrably non-vacuous). ⛔ Do not add the 179th.

    ⛔ Boundary — endorsed and restated

    Not a request to widen any exports map, and not to weaken check:published-files's GATED invariant. Sealing was right in both cases — #15325's triage and the reporter both say so. ⭐ The gap is visibility, not policy. A PR that closes this by re-opening a subpath has solved a different problem.

    ⛔ Not a claim, not a dispatch — routing only.


    Generated by Claude Code

  2. claude commented on Sep 5, 2026

    @claude
    Contributor

    Claim: PM seat domain:devx @ objectstack (#6023), session session_012zGPuVVX3deAx9LdjK8jCk, 2026-09-05T02:11Z — dispatching to an os-dev subagent on branch claude/issue-15589-consumer-specifier-ledger. Ruling: option A now — a hand-curated consumer-specifier ledger under packages/qa/downstream-contract (the accepted third-party-consumer pattern, one level down), each entry = bare specifier + consumer repo + date + the ratifying card, resolved through the workspace package's exports map with Node's REAL resolver from OUTSIDE the workspace (the pnpm pack + throwaway node_modules shape #15325's pin uses), red on the PR that drops a ledgered specifier naming the consumer; shrink-only with a recorded consumer-side migration; an owner line in the ledger header. Seed: @objectstack/cli/console (cloud objectos-runtime, #13662), @objectstack/cli/hook-body (hotcrm action-sandbox, #15325), @objectstack/cli/package.json. ⛔ B (changeset-gated seals in check:published-files) is a release-path policy change — NOT in this PR; the dev files it as a decision card with the measured count of past seals it would have refused. ⛔ Root package.json and packages/cli/package.json are held by other PRs — no edits there; wire the test inside the qa package's own manifest/vitest config. pm:queue → pm:dispatched, assignee set.


    Generated by Claude Code

  3. claude commented on Sep 5, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 15589,
      "status": "done",
      "branch": "claude/issue-15589-consumer-specifier-ledger",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/15717",
      "premise_still_valid": true,
      "summary": "Option A only, as ruled. Adds packages/qa/downstream-contract/consumer-specifiers.ledger.json (hand-curated, with purpose + OWNER=domain:devx + shrink-only rule + entry shape) seeded with the three ruled specifiers, and test/consumer-specifier-ledger.test.ts, which packs each ledgered package with `pnpm pack`, unpacks into a throwaway node_modules OUTSIDE the workspace, and resolves every specifier from a child node under both the require and import conditions, failing with a message naming the specifier AND the consumer. Resumed from a dead session's uncommitted worktree: the inherited draft was committed as-is first so a second restart could not lose it, then verified and corrected. Two corrections to the inherited draft: its bare-specifier check used toContain('/'), which a SCOPED bare package name also satisfies, so it admitted the entry it existed to reject (now compared against packageNameOf); and it cited a pin file that was not on the tree. Mid-branch, PR #15611 landed and opened `./hook-body` and `./package.json`. The draft's SEALED_TODAY set-equality did exactly what it was designed for -- merging main turned the suite red naming the two lines to delete -- so the ledger of losses is now EMPTY, its terminal state. The empty table is kept as the ratchet. Also added: a check separating ERR_PACKAGE_PATH_NOT_EXPORTED (a real seal) from MODULE_NOT_FOUND (a declared door leading nowhere -- unbuilt, or `files` not shipping what `exports` names), which matters now that all three doors are open and an unbuilt tree would otherwise read as 'hotcrm is broken'. `@objectstack/downstream-contract` is private:true so no changeset; skip-changeset applied via the additive labels endpoint and read back. No file outside the fence was edited.",
      "tests": "All on final head a4421eb89; exit codes captured before any pipe, verdict lines quoted rather than bare $?. Heavy runs via OS_VERIFY_LOCK_SLOT=issue-15589 scripts/pm/os-verify-lock.sh (one acquisition returned exit 99 queue-timeout = NOT MEASURED, nothing had started, place kept, re-acquired). BUILD: pnpm --filter '@objectstack/cli...' build exit 0. SUITE: new file exit 0, 'Test Files 1 passed (1) / Tests 12 passed (12)'; whole qa package exit 0, 'Test Files 3 passed (3) / Tests 31 passed (31)'; typecheck exit 0 (its tsconfig include covers test/**/*, so the new file is genuinely in the program). VERDICT command-exit 0. ABLATION (non-vacuity), run on the FINAL head against the COMMITTED implementation, with trap RESTORE_FN EXIT INT TERM and absolute paths: ./console deleted from packages/cli/package.json exports, never committed. Mutation confirmed ON DISK before measuring -- '\"./console\"' occurrences 1 -> 0, git diff HEAD --stat non-empty, resulting map printed -- so it was not a no-op edit. No rebuild was needed or done for this leg and that is deliberate: pnpm pack reads package.json from disk at pack time and only the exports map moved, no code; the built dist was already current from the build step above, and the suite's own guard throws if the root entry is absent. Result: RED, 2 failed / 10 passed, 'SEALED: @objectstack/cli/console no longer resolves under the `require` condition. This breaks objectstack-ai/cloud -- the `objectos-runtime` node server', probe reporting a real ERR_PACKAGE_PATH_NOT_EXPORTED from Node's resolver, which is what proves pack+unpack+resolve ran end to end. RESTORE proven, not assumed: git checkout HEAD -- ABSOLUTE_PATH, then git hash-object = 5d0ba78b18fc55d292d161974439b60d7c60bd43 = the HEAD blob, git diff HEAD empty, git status clean; re-run after restore 12 passed. CI WIRING, measured not asserted: TURBO_SCM_BASE=f99dbcc45 TURBO_SCM_HEAD=95d5cbb31 pnpm exec turbo ls --affected (i.e. #15611's own diff, which includes packages/cli/package.json) -> 7 packages including @objectstack/downstream-contract, so the suite DOES run on PR-side CI for that change; turbo ls @objectstack/downstream-contract lists @objectstack/cli, and at the merge base the package declared only @objectstack/spec/typescript/vitest. Layer B is the same edge: turbo.json gives @objectstack/downstream-contract#test dependsOn ['^build']. HONESTLY REPORTED: I tried twice to prove that edge by ablation and BOTH were confounded, so no clean ablation is claimed -- deleting the manifest entry alone left the edge (turbo builds the pnpm graph from pnpm-lock.yaml, which still carried it), and deleting the lockfile entry too made the lockfile a changed global input while --affected folds in uncommitted changes, widening the set to 10 instead of removing the package. Both mutations were restored with blob-hash proof. GATES: node scripts/pm/dispatch-gates.mjs --changed --commands --repo objectstack-ai/objectstack re-derived on the final head with NO stale-tree warning (change set: the 3 qa files + pnpm-lock.yaml) -> 85 commands. 81 exit 0, 0 RED, 4 NOT MEASURED. Quoted verdicts: 'check-nul-bytes: OK (scanned 7590 text file(s) ... no raw ASCII control bytes)'; 'check:published-files -- 69 publishable package(s) ... 69 declare an `exports` map' (independently corroborating the 69 in the option-B measurement); 'OK: 27 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob' (check:cross-package-test-inputs passes with NO new declaration needed, so scripts/cross-package-test-inputs.mjs and turbo.json were left untouched); 'dispatch-gates self-test: 1445 cases pass.'; 'check:partof-closing-keyword: PR #15717 carries no Part-of/closing-keyword contradiction.' Three gates first returned exit 124 -- that was MY 150s per-command cap, not a verdict -- and all three passed on re-run with room.",
      "mcp_calls": "0 -- the REST channel was probed first and works for repo-scoped reads and writes, so every GitHub read and write went over REST and no MCP GitHub tool was called. Channel note: REST /search/issues is 403 on this seat (while /issues and /pulls are 200), so the dedup before filing #15715 used a bounded REST LIST (open issues by label tooling + finding, 152 distinct) plus a local grep, with #15589 itself as the control term to prove the read was real. No wide-table scan, no MCP fallback needed.",
      "open_questions": [
        {
          "question": "Option B of #15589 -- should check:published-files refuse a PR that INTRODUCES an `exports` map unless the changeset is at least minor with a BREAKING-style consumer note? Filed as decision card #15715 with the measurement, not implemented here, per the ruling.",
          "options": [
            "B1 -- take B with a born-vs-retrofit discriminator: gate only when the map is added to a package.json that already existed in the merge base, plus subpath removal. Catches all 7 real cases, waves through all 44 harmless ones; cost is a history-dependent clause, which no other check:published-files clause is.",
            "B2 -- take B as worded: mechanical and history-free, but fires on 51 commits to catch a class that lives in 7.",
            "B3 -- narrow B to subpath REMOVAL only: cheapest correct-by-construction rule, but it would NOT have fired on #13123, which introduced rather than removed.",
            "B4 -- decline B: option A already reds on the PR that seals a LEDGERED specifier, naming the consumer. B is only worth its cost for consumers nobody has written down yet."
          ],
          "recommendation": "B1, falling back to B3 if a history-dependent clause is judged too costly for that gate. Measured on a4421eb89: 69 publishable packages declare an exports map, introduced across 51 distinct commits; 56 packages were BORN with the map and only 13 were RETROFITTED, across 7 commits. A package born with a map seals nobody -- there is no published predecessor to deep-import it -- so B as worded is ~7:1 false positives, and they cluster on new-package PRs, where a spurious \"declare who this breaks\" demand is least likely to be read closely and most likely to be satisfied with boilerplate. A gate answered by boilerplate 44 times stops being read by the 7th time it matters. B2 as literally worded should not be taken."
        }
      ],
      "out_of_scope_findings": [
        "filed as #15715: option B of #15589 as a decision card, with the measured count of past exports-map introductions (69 packages / 51 commits / 56 born-sealed / 13 retrofits across 7 commits) that B as worded would have refused.",
        "NOT filed, already covered: check:published-readme-exports and check:dual-build-cjs-loads both answer an unbuilt workspace with exit 1/3 rather than a skip. Dedup found #15285 and #15600 already open on exactly that, so no duplicate was filed.",
        "NOT filed, reported here instead: check:single-claim-paths cannot be run on this seat -- GITHUB_TOKEN is a 14-char placeholder and real credentials are injected by the agent proxy, which curl uses and the gate's own fetch (undici, no HTTPS_PROXY support) bypasses, so it 401s locally while the identical curl returns 200. Environment-specific, green on CI, and not obviously worth a card; PM to judge."
      ]
    }

    Generated by Claude Code

  4. claude commented on Sep 5, 2026

    @claude
    Contributor

    LANDED (partial — Part of) — PR #15717 merged as 1847594de (origin/main now eca504724). This card stays open.

    Probe on re-fetched origin/main: packages/qa/downstream-contract/test/consumer-specifier-ledger.test.ts is tracked and carries 16 it( cases (the PR's own run: Test Files 1 passed (1) / Tests 12 passed (12), whole qa package Tests 31 passed (31), typecheck exit 0). The suite itself was not re-run by this seat: NOT MEASURED here (needs a built @objectstack/cli... closure under the verify lock).

    Remainder: #15715 carries the part this PR did not land. Returning this card to pm:queue — stripping pm:dispatched and the assignee in the same action; it is re-selected on its own merits when its remainder's blocker clears.


    Generated by Claude Code

  5. os-steve commented on Sep 6, 2026

    @os-steve
    Collaborator

    Closing completed — both halves have now landed. Nothing remains.

    domain:devx @ objectstack seat (#6023), session session_01PU9zBGbH2s2ZtxSyu963M3, round 1, 2026-09-06T01:2xZ.

    This card proposed three options and was returned to pm:queue on 2026-09-05T05:40Z (5549735891) carrying only its remainder. That remainder was #15715, and it has now landed:

    half route landed
    Option A — consumer-specifier ledger under packages/qa/downstream-contract, resolved through the real exports map from outside the workspace PR #15717 1847594de, 2026-09-05
    Option B — check:published-files refuses an unannounced seal split to #15715, ruled B1 by the maintainer (5550130523, verbatim 「同意」), implemented as the ANNOUNCED invariant PR #16082 → 64011dd3f, 2026-09-06T01:22:25Z

    ⇒ The card's dispatchable residue is empty, so it closes completed rather than returning to the queue. ⛔ It is deliberately not closed as a duplicate of #15715: #15715 is one of its two halves, not a restatement of it.

    The two halves close different holes, which is why both were worth landing

    Triage's reading on this card (5548057783) called it precisely and is worth preserving as the reason the pair exists: "A alone goes stale … B alone predicts nothing", and the structural fact underneath — inside the monorepo nothing is sealed, because every in-repo consumer reaches any file through a relative import, a vitest alias or a paths entry. Nothing here could ever have gone red on its own; the coverage had to come either from outside knowledge (A) or from making the act itself loud (B1).

    pm:queue stripped in the same stroke as the close. No assignee to clear.


    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

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions