Skip to content

fix(runtime): two packages declaring the same job name both run, and uninstalling one stops only its own job - #21633

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21602-package-scoped-job-identity
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21602-package-scoped-job-identity

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21602
Clause-②: no

What changes

The metadata registry keys a packaged item by package and name (PACKAGE_ID:JOB_NAME), so two packages may each declare a job called shared_tick. IJobService keys a job by one string and replaces any job with the same name. Before this change, scheduleAppArtifactJobs passed the bare authored name, so the second package's install silently replaced the first package's job.

scheduleAppArtifactJobs (packages/runtime/src/app-artifact-handlers.ts) now asks jobKeyFor for the job service's name for each job. The answers are checked in this order:

  1. The key this app already holds the job under. A reinstall replaces its own job and keeps its catalogue row and run history.
  2. Otherwise, the authored name, when no other package holds it on this job service.
  3. Otherwise, the registry's package-scoped key PACKAGE_ID:JOB_NAME. No authored name can equal it, because JobSchema.name is snake_case and never contains a :. When this branch applies, an info line names the package that holds the authored name and the key used.

The ownership record from the job-half PR (#21584) now maps each authored job name to the key it was scheduled under, per app. retireAppJobs (replace) and the runtime.package-jobs uninstall cleanup cancel by that key, so neither path ever cancels another package's job.

There is no IJobService contract change, no packages/spec change and no packages/services/** change. Function-name resolution for hooks belongs to the maintainer's #21604 decision and is not touched here.

A1: reach, at the public door, on origin/main 045b946256

The new pin packages/cli/test/package-install-local-jobs-shared-name.integration.test.ts was run against the base dist/, before the change. Three packages each declare a body job shared_tick, and each writes rows with its own marker into the host's object. Result: 4 failed, 5 passed.

  • ALPHA's job stopped once BETA and GAMMA installed: expected 8 to be greater than 8. ALPHA wrote no new row after the later installs.
  • The sys_job catalogue read [ 'shared_tick' ]: one row for three declared jobs.
  • ALPHA's job stopped at BETA's uninstall (it had already stopped).
  • ALPHA's job did not run after the restart: the rehydrate displaced it again.

A2: census of every reader of the scheduled identity

Found by symbol walk: every IJobService implementation, every caller of schedule / cancel / trigger / replay / getExecutions / listJobs / listExecutionsByStatus outside service-job, and every sys_job / sys_job_run reader.

Reader Keys by the scheduled name? What the package-scoped key does to it
IntervalJobAdapter (jobs map; schedule/register/cancel/trigger/getExecutions/listJobs) yes An opaque string, used consistently by every verb. No shape check.
CronJobAdapter (jobs map; croner registry name NAMESPACE::NAME) yes Same. The registry name already contains ::, so a : in the key is inert.
DbJobAdapter: sys_job row (upsertJobRow / setActive / bumpJob, all where: { name }) yes The row is named by the key. sys_job.name is unique: 'global', so two coexisting jobs need two strings there.
Run history: sys_job_run.job_name (startRun), in-memory executions, listExecutionsByStatus (jobId: r.job_name) yes Run rows carry the key.
core fallback memory-job.ts yes Same as the interval adapter.
Handler context { jobId } the adapters pass to a run yes Not visible to job code. A handler job's context overrides jobId with the authored name (unchanged line, pinned for a scoped job). A body's ctx carries no job name (jobBodyRunnerFactory logs and tags origin with the authored job.name).
Admin listings not directly No REST route or MCP tool in this repo lists, triggers, cancels or replays a package job. The Setup "Background Jobs" grid reads sys_job / sys_job_run through the generic data API (apiMethods: ['get','list']).
Cancel paths: retireAppJobs, the runtime.package-jobs uninstall cleanup yes Now cancel by the recorded key, so only the uninstalling or replacing package's job stops.
Other schedulers on the same service (flow-schedule:*, flow-time-relative:*, flow-wait:*, approvals-sla-escalation) their own names They never read a package job's name. A snake_case authored name cannot equal theirs, because theirs contain -.
Trigger / replay paths n/a No caller for a package job in this repo.
Metrics (jobScheduleFailuresTotal label job), log lines, the binder's own return arrays authored name Unchanged. Logs add scheduledAs meta.

What the readers show, measured at the door after the change:

  • Single package: sys_job names [shared_tick] and sys_job_run.job_name [shared_tick], the authored name. This matches the pre-change reading of the same phase.
  • After two more packages declared the same name: sys_job and sys_job_run both read [com.example.sharedbeta:shared_tick, com.example.sharedgamma:shared_tick, shared_tick]. The first package keeps the authored name, its row and its history. Only the later colliding packages read the scoped identity.

A3: the carrier

JobScheduleOptions (packages/spec/src/contracts/job-service.ts) holds only retryPolicy and timeoutMs, so no existing field can carry the package. The scope rides on the name argument, and only when another package already holds the name. A runtime in which no two packages share a job name schedules every job under its authored name, so its visible names and run history are unchanged. That is pinned in the unit suite and at the door. No reader outside domain:cli needed an edit.

A4: pins

Unit (packages/runtime/src/app-artifact-handlers.jobs.test.ts, new describe block):

  • A single package's jobs keep their authored names across a reinstall (control).
  • The second package gets PACKAGE_ID:shared_tick, nothing is cancelled, and each key runs its own package's body. The info line names the holder.
  • A reinstall of either package replaces only its own job.
  • Uninstalling the scoped holder cancels only its scoped key, and uninstalling the bare-name holder cancels only the bare name.
  • When the scoped holder's next version drops the job, only its scoped key is cancelled.
  • A third package is also scoped. Once the bare-name holder is gone, a newcomer takes the bare name and the scoped holder keeps its key.
  • A handler job under a scoped key still receives the authored jobId.
  • A failed uninstall cancel names shared_tick (scheduled as PACKAGE_ID:shared_tick).

The #21489 test that pinned last-scheduler-wins (another app's jobs are never cancelled — not even one that took over a name…) pinned exactly the branch this change removes. It is replaced: this app's empty version now cancels its own shared_name and mine_only, and the other app's com.example.other:shared_name and theirs_only keep running.

Install-local, real built packages (package-install-local-jobs-shared-name.integration.test.ts, 9 cases):

  • Single-package catalogue reads the authored name.
  • ALPHA keeps running after BETA and GAMMA install, and both later jobs run.
  • The catalogue shows the scoped identities.
  • Uninstalling BETA (a scoped holder) stops BETA only.
  • ALPHA and GAMMA run after a restart.
  • Uninstalling ALPHA (the bare-name holder) stops ALPHA only.

A5: reverse verification (ablation), run at 07d4deb5fb

Tests

Final head 2279c378f6. Its only change from 2f9097c7c2 is a one-line doc comment in app-artifact-handlers.ts.

  • Gates. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 62 commands. All 62 were run on 2279c378f6, each exit 0. --ran reports: 62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN. I also ran the 5 roster gates whose roster sits under a touched directory (check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity, check:route-ledger-census): all exit 0.
  • Lint. pnpm lint (full repo, eslint . --no-inline-config) exit 0 on 2279c378f6.
  • Runtime unit pins and typecheck on 2279c378f6: app-artifact-handlers.jobs.test.ts 35/35. pnpm --filter @objectstack/runtime typecheck OK.
  • Full suites, run on 2f9097c7c2, which has the same source apart from the comment:
    • @objectstack/runtime: 318 files, 4530 passed, 19 skipped.
    • @objectstack/cloud-connection: 36 files, 437 passed.
    • @objectstack/cli --project unit: 255 files, 3745 passed. This includes the tier-partition pin, which classifies the new file as integration.
    • @objectstack/cli typecheck OK. The new test file is in tsconfig.test.json's program (counted with --listFilesOnly).
  • Install-local pins on built dist/:
    • package-install-local-jobs.integration.test.ts and …-jobs-shared-name.integration.test.ts: 20/20.
    • package-install-local-uninstall-cleanups.integration.test.ts: 16/16.
  • Liveness evidence. pnpm --filter @objectstack/spec exec vitest run --project repo scripts/liveness/evidence.test.ts: 42/42. No exported symbol was renamed. The internal claimJobName became claimJobKey, and no ledger names it.

Acceptance notes

  • Collision case only: the later package's job is visible under its scoped key. In sys_job / sys_job_run it reads PACKAGE_ID:JOB_NAME. This is forced by sys_job.name being unique: 'global': two jobs that both run need two strings there. No reader shows a moved name in a runtime where names do not collide, and that is pinned. Clause-②: no is copied from the claim and not re-declared.
  • Restart order, read from code, not measured. On a restart, the bare name goes to the first package the boot schedules. Install-local rehydrates in ledger file-name order (readdirSync), while hot installs schedule in install order. Suppose a colliding package that was installed later sorts first. The two then swap names at the restart, and the JOB_NAME row's run history continues with the other package's runs. Both jobs still run. The pin's package ids sort in install order. Carrier: none.
  • sys_job.active is not reconciled on boot, read from code, not measured. DbJobAdapter never resets the flag at startup, so a key that nothing schedules after a restart keeps active: true. This already applies to any job dropped between boots. Carrier: none.
  • Liveness ledger wording. packages/spec/liveness/job.json's name evidence says the name "is the scheduling key passed to svc.schedule". That is still true except in the collision case. The quoted anchor still resolves (evidence test green). packages/spec is outside this card's lane. Carrier: none.
  • [Decision] security(objectql): may a hook's handler name bind to a function another package registered (the engine-wide fallback HookSchema.handler declares), or does name resolution stay inside the hook's own package (#21585 option B) #21604 is not addressed here (hook function-name resolution).

Generated by Claude Code

claude added 4 commits October 3, 2026 19:48
…ce when another package declares the same name

The metadata registry keys a packaged item by <packageId>:<name>; the job
service keys by one string and replaces. scheduleAppArtifactJobs now
schedules a job under its authored name unless another package already
holds that name, and then under the registry's package-scoped key, so both
jobs run. The ownership record maps each authored name to the key it was
scheduled under, so a replace or an uninstall cancels only its own
package's job. A runtime where no two packages share a job name schedules
every job under its authored name, unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 3, 2026
@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

10 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 045b946256d988653fdca185c7fd33d6d86bd78d → packageMentionDocs.

Which tree this was computed on

This run read content/docs from f9574370cabdf7524caefb6ddfea986caf81b0a2 — the merge of head 2279c378f6355f7852a9b8ff54e4a050e171bf68 into base 045b946256d988653fdca185c7fd33d6d86bd78d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f9574370cabdf7524caefb6ddfea986caf81b0a2 && git checkout f9574370cabdf7524caefb6ddfea986caf81b0a2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 045b946256d988653fdca185c7fd33d6d86bd78d 2279c378f6355f7852a9b8ff54e4a050e171bf68 && git checkout -B drift-repro 045b946256d988653fdca185c7fd33d6d86bd78d && git merge --no-ff 2279c378f6355f7852a9b8ff54e4a050e171bf68

node scripts/docs-audit/affected-docs.mjs --json 045b946256d988653fdca185c7fd33d6d86bd78d

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 3, 2026 21:20
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 3, 2026 21:20
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit 6946f2f Oct 3, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21602-package-scoped-job-identity branch October 3, 2026 21:56
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…t decided them (stage 12 of objectstack-ai#20595) (objectstack-ai#21635)

Part of objectstack-ai#20595
Clause-②: no

## What changed

Stage 12 of the `domain:engine` lane of the dead-citation sweep:
`packages/formula/**`, comment and docblock prose only, per the claim
(`5973031304`). Stages 1 to 11 landed as `a7d9768ec`, `d150c3039`,
`4bf4e7e70`, `13a24ece2`, `db0cf2231`, `85986144c`, `48fa7a381`,
`c205b6c35`, `c98a72d69`, `fd5a1cd59` and `f97660cdd`. objectstack-ai#20595 stays
open: the lane's other remaining package, `metadata-fs` (2 census sites
after this stage), is not touched here, and neither are the test-string
sites the card carries for a widened stage.

Every comment or docblock site in the package that cited a tracker
number answering 404 is rewritten in ruling C+D's form C (record
`5749154545` on objectstack-ai#19123): the ADR when one records the decision,
otherwise the commit in this repository's history that made it. That is
**6 sites on 6 lines in 2 files, all citing one number (objectstack-ai#6290), all
re-anchored to one commit, `e9b526597`**:

- **4 census sites** (4 lines, `src/cel-engine.ts` :120, :147, :160,
:173): the whole `allocated-but-absent` population of the gate's own
census in this package at the base;
- **2 test-comment sites** (2 lines, `src/validate.test.ts` :933 and
:958), which the census defers by its test-file exclusion. Same number;
- **outside the census glob, inside the claimed surface: none.**
`README.md`, `package.json`, `tsconfig.json`, `tsconfig.test.json`,
`test-typecheck-debt.json` and `vitest.config.ts` carry 10 citations
between them, and all 10 resolve on the enumerated board (below);
- **dead comment ids: none.** The package's only ten-digit id,
`5865693155` (the ruling record on objectstack-ai#20399, at `matches-filter.ts:808`
and `matches-filter-empty-operator.test.ts:8`), answers 200.

**Anchors: 1 number, by commit; 0 by ADR, 0 by repository qualifier; 1
sha.** `e9b526597` is the anchor the `packages/lint` sweep (`aa23e2c8f`)
already chose for the same number; it is reused here and re-proven below
for this package's sentences.

Only comments changed. Both files keep their line counts (6 lines out, 6
in, plus the changeset), so no line citation into either file moves. No
code token moves (the guard below). All 12 changed lines open with a
comment marker. **No citation number is added**: the `+` lines carry no
tracker number at all, and their only new nine-hex span is `e9b526597`,
6 times.

**A `patch` changeset**: 2 of the 4 rewritten non-test lines are in the
published `dist` (the `SCOPE_ROOTS` docblock in the `.d.ts`, and one
`//` line inside that list that esbuild keeps in the JavaScript), and
`dist` is not byte-identical with the base text (see Changeset).

## H0: the package and its size

The gate's own `node scripts/check-issue-citations.mjs --census --json`
at base `045b94625` (the before run below), `allocated-but-absent` per
remaining `domain:engine` package:

| package | before | after this stage |
|---|---|---|
| `formula` | **4** | **0** |
| `metadata-fs` | 2 | 2 |
| `drivers/driver-mongodb`, `drivers/driver-turso`, `metadata-core`,
`core`, `metadata-protocol`, `objectql`, `metadata`,
`drivers/driver-sql`, `drivers/driver-memory`,
`drivers/driver-sqlite-wasm`, `plugins/plugin-pinyin-search`,
`platform-objects` | 0 each | 0 each |

The lane total goes 6 to 2. `formula` reads 4, as at stage 11's head
census (`b69c176e9`): the 4 sites, the one number and the one file the
dispatch named. So the stage went ahead.

## Census: `formula`, before and after

**Instrument (A1).** The gate's own `node
scripts/check-issue-citations.mjs --census --json`, read-only and
unchanged. The count is its `allocated-but-absent` findings under
`packages/formula/`.

| reading | tree | board | whole-repo `allocated-but-absent` | sites |
lines | files | numbers |
|---|---|---|---|---|---|---|---|
| before | base `045b94625`, run 20:12:19Z to 20:16:21Z | enumerated,
195 pages, frontier objectstack-ai#21629, 19,450 records | 119 | **4** | 4 | 1 | 1 |
| after | `7a520db9d`, run 20:22:39Z to 20:25:58Z | enumerated, 195
pages, frontier objectstack-ai#21630, 19,451 records | 115 | **0** | 0 | 0 | 0 |
| head | `b89eb86cb`, run 20:48:23Z to 20:51:45Z | enumerated, 195
pages, frontier objectstack-ai#21633, 19,454 records | 115 | **0** | 0 | 0 | 0 |

The whole-repo drop is 4, and the before and after finding sets differ
by exactly the 4 rows of this package, removed; none was added.
`resolves` (35,759), `resolves-as-pull-request` (2,383) and
`cross-repo-unjudged` (1,254) did not move. The head's only later commit
is the changeset; the head run's finding set is identical to the after
run's, line numbers included.

**Supplementary instrument, the whole package.** The census reads
neither test files nor strings nor files outside `src`. A second reading
runs the gate's own exported `extractCitations` (whole-file and
comment-prose projections) over every tracked file in the package (69)
and classifies each citation with the gate's `classifyCitation` against
one board enumerated by the gate's `enumerateBoard` (195 pages, frontier
objectstack-ai#21629, 19,450 records, read from 20:17:47Z to 20:21:16Z), the same
board for both readings.

| reading | citations | dead | src comment | test comment | test string
| other files | changelog |
|---|---|---|---|---|---|---|---|
| before, `045b94625` | 763 | **12** | 4 | 2 | 2 | 0 | 4 |
| after, `7a520db9d` | 757 | **6** | 0 | 0 | 2 | 0 | 4 |

The citation count drops by 6, the 6 rewritten sites; no respelling
stays a citation. The live counts did not move (src comment: 229 resolve
as issues, 6 as pull requests, 1 cross-repo; test comment: 148, 11 and
1; test string: 100, 1 and 2; files outside `src`: 10 resolve as
issues). A third, raw reading (every `#` followed by 2 to 6 digits,
whatever surrounds it, `CHANGELOG.md` aside) counts 527 before and 521
after: also a drop of 6.

**Single reads** over the issues endpoint (20:21:27Z): objectstack-ai#6290 answers
404; the controls objectstack-ai#6584, objectstack-ai#6713, objectstack-ai#6146, objectstack-ai#6798, objectstack-ai#6585, objectstack-ai#6711, objectstack-ai#16412 and
objectstack-ai#3447 answer 200. Comment `5865693155` answers 200, and the control
`5973031304` (the claim) answers 200. No `issuecomment` or
`discussion_r` link stands in the package (git grep exit 1).

## Per-number table

`src` counts census sites, `test` the test-comment sites.

| number | src | test | anchor | kind | source | what it decided |
|---|---|---|---|---|---|---|
| `objectstack-ai#6290` | 4 | 2 | `e9b526597` | commit | reused (`packages/lint` sweep
`aa23e2c8f`), re-proven here | `current_user`, ADR-0068 D1's canonical
user root, joins `SCOPE_ROOTS`, and the field-level `*When` rejection
stops falling out of that list's omission and becomes
`@objectstack/lint`'s own rule with its own prescription. It wrote the
`'current_user'` entry with its comment, the block comment after the
list, and both `validate.test.ts` docblocks (the squash of PR objectstack-ai#6584) |

The proof, per the earlier stages' standard:

- `git rev-parse --disambiguate=e9b526597` matches exactly one commit,
`e9b5265970029092a8b7124f8c2df98aa6ac0b15`.
- `git merge-base --is-ancestor` puts it under the base `045b94625` and
under `origin/main` `045b94625` (exit 0 both; exit 0 is self-proving,
and the repository is not shallow).
- It names objectstack-ai#6290 in its message (the subject's suffix) and in its diff
(18 diff lines).
- `git blame` at the base puts 5 of the 6 changed lines on it. The
sixth, `cel-engine.ts:120`, was written by `d5e9f6e7c`, a later change
that cites objectstack-ai#6290 as the change in which `current_user` arrived. That
arrival is `e9b526597`'s diff: the `'current_user',` entry added to
`SCOPE_ROOTS`.
- No ADR or ruling record names objectstack-ai#6290 (git grep over `docs/adr`, exit
1). ADR-0068 D1 records that `current_user` is the canonical spelling, a
decision these sentences already cite by its own ADR id. It does not
record the list change that the number stood for.

## Wordings to check

All 6 rewrites swap a tag in place, in stage 1's forms:

- `(#N)` became `(commit SHA)` at `cel-engine.ts:147` and `:160`.
- `[#N]` became `[commit SHA]` at `validate.test.ts:933` and `:958`.
- 「`current_user` arrived in objectstack-ai#6290」 became 「`current_user` arrived in
commit e9b5265」 (`cel-engine.ts:120`). This is the `packages/lint`
sweep's wording for the twin sentence in `validate-expressions.ts`.
- 「Before objectstack-ai#6290 that」 became 「Before commit e9b5265 that」
(`cel-engine.ts:173`).

**A cross-package quotation stays verbatim.**
`packages/lint/src/validate-expressions.ts:769` quotes this package's
`SCOPE_ROOTS` entry as "the last one this list was missing". That phrase
is all on `cel-engine.ts:146`, which this stage does not touch, and the
rewritten `(commit e9b5265)` is on the next line. No other file cites
a line of `cel-engine.ts` (git grep for `cel-engine.ts:` followed by a
line number, no hit).

**No reflow.** No line was reflowed, so `:120`, `:173` and
`validate.test.ts:958` are now longer than their block's wrap.
`eslint.config.mjs` declares no line-length rule, and a reflow would
move neighbouring lines.

## Sites left

- **In comments (src, test, outside the glob): none.**
- **String literals: 2 test-string sites, 1 number**: the `it` titles at
`validate.test.ts:950` and `:964` (objectstack-ai#6290). Strings are outside this
stage's surface, and the number's anchor is in this stage's table.
Non-test strings cite no dead number.
- **Outside `src`:** the release-owned `CHANGELOG.md` names objectstack-ai#6290 on 4
lines (2875, 2946, 4350, 4421); left.
- **Outside this package:**
`packages/lint/src/validate-expressions.test.ts` cites objectstack-ai#6290 on 9 lines.
That is another lane's test file, and the `packages/lint` sweep's own PR
notes it.

## Mechanical guard: no code token moves

The guard compares base `045b94625` against the tree over both touched
files, with TypeScript 6.0.3, to the earlier stages' two-reading
specification. Stage 11's guard script was a scratch file, so it was
rewritten here to that specification and proven with the controls below.

- **Reading 1**: the parser's leaf nodes, from a `forEachChild` walk.
Comments are trivia there, and JSDoc is never visited. A leaf that is
not itself a token is re-scanned with trivia skipped.
- **Reading 2**: the full token stream in parser context, from a
`getChildren` walk, with JSDoc nodes skipped. String, template and
numeric literals are compared in full on both readings.

Results, at `7a520db9d` (the later commit touches neither file):

- Real run: 13,628 base tokens, **0 files with a token change** (exit
0).
- Comment controls: 「Membership of THIS list can.」 to 「… CAN.」
(`cel-engine.ts`) and 「Pinned as behaviour」 to 「Pinned as BEHAVIOUR」
(`validate.test.ts`). 0 files changed (exit 0 each).
- Positive control, an identifier (`export const SCOPE_ROOTS` to
`XSCOPE_ROOTS`, `cel-engine.ts`): DIFFER on both readings (exit 1).
- Positive control, a string literal (`toContain('current_user')` to
`'current_useR'`, `validate.test.ts`): DIFFER on both readings (exit 1).
- Positive control, a template literal (the `${root}.x` template to
`${root}.y`, `validate.test.ts`): DIFFER on both readings (exit 1).
- Positive control, a numeric literal (`maxAstNodes: 256` to `257`,
`cel-engine.ts`): DIFFER on both readings (exit 1).

Each mutation went through `scripts/ablation-replace.mjs` (wrap mode;
the anchor hit 1 before and 0 after, and the blob changed). It ran under
a shell trap that restores by absolute path from `HEAD`. Each restore
was proven equal to its `HEAD` blob (`6fa1738be716`, `832c4f069ec7`),
and afterwards `git diff HEAD` was empty and the tree clean.

## Changeset: `patch` (`dist` measured)

`files[]` is `dist`, `README.md` and `CHANGELOG.md`, and the package is
not private. One script ran under the shared verify lock (VERDICT
command-exit 0, held 75s, shared-box seconds), at `7a520db9d`. It built
the dependency closure first (`pnpm --filter '@objectstack/formula^...'
build`, exit 0), then ran the package's own `build` (tsup and
`check-dts-emitted`) three times, exit 0 each:

- **Leg 1**, the head text: 6 `dist` files hashed (`index.js`,
`index.mjs`, their sourcemaps, `index.d.ts`, `index.d.mts`). 2 of the 4
rewritten non-test lines appear verbatim in `dist`:
- the `SCOPE_ROOTS` docblock line (`cel-engine.ts:120`) in `index.d.ts`
and `index.d.mts`;
- the `// (commit e9b5265).` line inside the list
(`cel-engine.ts:147`) in `index.js` and `index.mjs`.
  
  The other 2 (`:160`, `:173`) sit in a block comment the build drops.
- **Leg 2**, the base text put back in `cel-engine.ts` (proven equal to
its base blob `667f87448eeb`, written to the tree only): 4 of the 6
files differ from leg 1 (`index.d.ts`, `index.d.mts`, `index.js`,
`index.mjs`); the two sourcemaps do not.
`scripts/ablation-dist-preflight.mjs` finds the base marker 「arrived in
objectstack-ai#6290) is silently unreported at」 in the 2 declaration files (exit 0).
- **Leg 3**, after the proven restore (equal to its `HEAD` blob
`6fa1738be716`, `git diff HEAD` empty, porcelain empty): all 6 files are
byte-identical to leg 1. The preflight's `--absent` reading exits 0 with
a clean tree. So the build is deterministic, and the difference is the
rewrite.

So the rewrite ships. `.changeset/20595-formula-provenance-anchors.md`
declares a `patch` for `@objectstack/formula`, comment text only, with
the claim's `Clause-②: no` line. The anchor is a commit, so the
changeset names no ADR, repository qualifier or bracketed substitution.
It says which published files carry the reworded text, as measured
above. The changeset commit touches no file under `packages/formula`.

## Gates (head `b89eb86cb`)

- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` at `b89eb86cb` (3 paths against
merge base `045b94625`) derived 63 commands. All 63 ran (20:36:36Z to
20:47:06Z, after the workspace build), each exit code captured before
any pipe: 63 exit 0. `--ran` reports 「63 derived, 63 run, 0
NOT-MEASURED, 0 UNRUN」 (a derived zero) and exits 0.
- The PM's lead derivation (49 commands, one path) is a subset. The
extra 14 are:
- the eight families the `.changeset/` path adds: the ADR-0087
registration and empty-changeset pairs, `check:objectui-changeset`,
`check:pm-changeset-deadline-census` and two release self-tests;
    - `check:type-check-coverage` and `check:type-check-debt`;
- four gates whose sources name the touched files:
`check:engine-double-contract`, `check:objectql-double-limit`,
`check:query-options-erasure` and `check:where-matcher`.
- **Named readings:**
- `node scripts/check-issue-citations.mjs` exits 0 (「no issue citations
added against 045b946」).
- `pnpm check:issue-citations` exits 0 (its self-test, 173 cases, 9
batteries).
- `pnpm check:doc-authoring` exits 0 (the sibling-package prose-id
baseline holds, no growth).
- `pnpm check:nul-bytes` exits 0 (9,998 files, no raw control bytes),
and a control-byte grep over the 3 changed files finds none (exit 1).
- The changeset gates exit 0: `check-adr-0087-registration` (「1
non-breaking changeset(s) seen」), `check-empty-changeset` (「1 declaring
changeset(s) added」), `check-changeset-no-major` (「no `major` bump」),
and `check:changeset-gate-self-tests`. The Clause-② level axis of
`check-changeset-no-major` reads the pull request body, so it does not
apply to a local run; that reading is CI's.
- **Build, tests and typecheck, under the verify lock**, at `b89eb86cb`:
- The workspace build (`turbo run build --filter='./packages/*'
--filter='./packages/*/*' --concurrency=2`): VERDICT command-exit 0,
held 213s, shared-box seconds; 71 of 71 tasks, 17 cached.
- The tests and typecheck: VERDICT command-exit 0, held 19s. `pnpm
--filter @objectstack/formula test`: 43 test files pass, 1,257 tests
pass. `pnpm --filter @objectstack/formula typecheck` (`tsc --noEmit` and
`check:test-typecheck` over `tsconfig.test.json`) exits 0.
- `tsc --listFilesOnly` puts both touched files in a program:
`cel-engine.ts` in both configs, and `validate.test.ts` in
`tsconfig.test.json`, whose program holds all 43 tracked test files
(`tsconfig.json` excludes them).
- No importing package owes a run, because the declaration files change
only in comment text.
- **Lint, as a proven narrowing, at `b89eb86cb`:**
- eslint ran with inline config disabled (`--format json`) over the 2
touched files plus `dist/index.js` as the control.
- 3 results: 0 errors, and 1 warning, which is the control's ignore
notice. Neither touched file is reported ignored, and `--print-config`
resolves a config for each.
- `eslint.config.mjs` never enables type-aware linting (its lines 327
and 328 say so; `--print-config` shows no `parserOptions.project` for
either file), so a comment edit cannot move the verdict on an untouched
file.
  - The repo-wide `pnpm lint` is CI's run.

## Acceptance notes

- **Base, and no merge.** The dispatch read `origin/main` at
`045b94625`, and the worktree was cut there. Every reading above is on
this branch's own tree. Just before this PR was opened, `main` moved one
commit, `a4f0cb0a4`: a `service-automation` change, with its changeset
and one lockfile line.
- It touches neither `packages/formula`, `check-issue-citations.mjs` nor
`dispatch-gates.mjs`.
- A local `git merge-tree` of the head with it is clean (exit 0), and
the anchor `e9b526597` is under it too (`--is-ancestor`, exit 0).
- The branch is not merged with it: the gate derivation reads the
three-dot change set against the merge base `045b94625`, and CI and the
merge queue run on the merged ref.
- **History.** The repository is not shallow (`git rev-parse
--is-shallow-repository` answers false), so no deepening was needed
before the blame, ancestry and history readings.
- **The same dead number outside this package**, each left to its own
carrier: `formula`'s 2 test-string sites (above); `CHANGELOG.md`
(release-owned); `packages/lint`'s test file (another lane).
- **Wording only:** no line without the number was changed.

---
_Generated by [Claude
Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants