Skip to content

chore(turbo): opt out of the agent-guidance block in the root turbo.json - #21151

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-21146-turbo-agent-guidance
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-21146-turbo-agent-guidance

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21146
Clause-②: no

Adds "agentGuidance": false to the root turbo.json, and nothing else (1 file, +1 line).

Why

turbo 2.11 (landed with the 2.11.5 bump) defaults the root option agentGuidance to true: when it detects an AI agent it maintains a managed block, the BEGIN/END turborepo-agent-rules pair, in the repository-root AGENTS.md before repository-scoped commands, and the block tells the agent to keep it committed. AGENTS.md is a governed surface, so every agent worktree that obeys turns its PR into a human-approval PR. The opt-out is the root-only key turbo's own bundled docs/reference/configuration.mdx names. No other file is in the surface; AGENTS.md was never staged (see Evidence).

Evidence

All commands run in this worktree at base e952cff with AI_AGENT set in the environment, turbo 2.11.5 (node_modules/.bin/turbo --version). AGENTS.md sha1 before every leg: 99e45344e3582f8b5e9c9e9987fc3c8375c1999d.

  • Positive control, main's turbo.json with no opt-out: turbo ls exit 0, then git diff --exit-code -- AGENTS.md exit 1, 1 file changed, 11 insertions(+). The 11 lines are the managed block appended after the last line of AGENTS.md. Restoring with git restore --source=HEAD --staged --worktree AGENTS.md and running turbo ls again re-added the same block (exit 1 again), so the write is repeatable and not a one-off.
  • Treatment, same tree with the one line added: turbo ls exit 0, git diff --exit-code -- AGENTS.md exit 0, git status --short lists only turbo.json, AGENTS.md sha1 unchanged. turbo run build --dry=json --filter=@objectstack/types exit 0, git diff --exit-code -- AGENTS.md exit 0 again.
  • Commit hygiene: git diff --cached --name-only before the commit lists turbo.json only (zero lines match AGENTS.md); git show HEAD --name-only lists turbo.json only.

Readers of turbo.json

None pins the top-level key set, so nothing is loosened. Readers measured at HEAD 3e3b4c9 (tree identical to HEAD, git diff HEAD --quiet exit 0), each exit code taken before any pipe:

  • Derived by dispatch-gates.mjs --commands (9 commands, derivation identical before and after the commit): check-closing-keyword-parity.mjs (plus --self-test), check-comment-mask-corpus.mjs, check:driver-memory-census, check:gitlink-declared, check:nul-bytes, check:refd-timer-probe, check:turbo-task-graph, check:watch-hint-literal: all exit 0. --ran reconciliation: 9 derived, 9 run, 0 NOT-MEASURED, 0 UNRUN, every family carrying an exit code.
  • Readers found by grepping the tree for turbo.json read points, run beyond the derivation: check-ci-filter-parity.mjs (and --self-test), partition-test-shards.mjs --self-test, check:cross-package-test-inputs, check:examples-live-imports, check:type-check-coverage: all exit 0. They read tasks, globalDependencies and per-task inputs; none iterates the top-level keys.
  • NOT MEASURED: live pnpm check:dev-prereqs (exit 1, "The workspace is not built": 67 of 67 packages have no dist/, a prerequisite and not a verdict; this diff touches no package, so no build was owed). Its --self-test is exit 0, and its only read of turbo.json is globalDependencies through scripts/build-input-hash.mjs, which this change does not move.
  • Not run, CI-owned: repo-wide pnpm lint and the rest of the check:* farm. scripts/ci/select-gate-families.sh classifies turbo.json as root config, so CI runs every gate family on this PR by design.

Changeset

None owed: the root turbo.json publishes nothing (root config, private root package). .github/workflows/pr-automation.yml's Check Changeset job counts changesets this PR adds and has no path exemption, only the skip-changeset label, so the PR carries that label.

Acceptance notes

  • Per turbo's docs the opt-out stops future writes and does not remove a block already present. A worktree that already carries it restores with git restore --source=HEAD --staged --worktree AGENTS.md; never commit the block.
  • turbo.json is not on the governed-surface register, so this PR is not a Tier H or Tier S landing.

Generated by Claude Code

turbo 2.11 (landed with the 2.11.5 bump) maintains a managed block in the
repository-root AGENTS.md whenever it detects an AI agent, before
repository-scoped commands. AGENTS.md is a governed surface and the block
tells agents to commit it, so the default turns every agent PR that obeys
into a human-approval PR. The root-only option agentGuidance set to false
is the opt-out turbo's bundled docs name.

Claude-Session: https://claude.ai/code/session_018gA1pE6eJtwHhqx72G8U9X
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Oct 1, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 10:34
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 10:34
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit fde553c Oct 1, 2026
32 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21146-turbo-agent-guidance branch October 1, 2026 11:10
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…isplay field the caller may query (objectstack-ai#21136)

Part of objectstack-ai#21062
Clause-②: yes (narrowing)

Position 2 of objectstack-ai#21062 only. Position 1 (the public-form submit read-back,
in `plugin-security`) is a separate PR on its own branch, so this PR
does not close the card.

## What this changes

`GET /forms/:slug/lookup/:field`, the public form's lookup picker,
matches the visitor's search and orders its rows by one key. The handler
took that key blindly from the first entry of
`publicPicker.displayFields`. A field whose masking rule applies to the
caller is served to it masked and is not queryable, and the engine
refuses a search or an order on it with `403 PERMISSION_DENIED`. Since
PR objectstack-ai#21051 that includes the caller who resolves no permission set. So a
picker whose first display field declares a masking rule answered `403`
to every caller the rule applies to, on every request.

The key is now the first display field the caller may query on, by the
security service's published answer
(`ISecurityService.getQueryableFields`, objectstack-ai#20935). The handler reads it
through the security service it already resolves
(`resolveSecurityService`), for the context it already builds. No
masking rule is derived in `rest`.

- **No display field queryable:** the route answers the engine's refusal
for those fields, in the same envelope (`403 PERMISSION_DENIED`, the
flat body `mapDataError` already produces for this route) and with the
engine's sentence, before any query runs.
- **No security service:** nothing is masked on such a deployment, and
the first display field stays the key, as before.
- **A security service that cannot give the query answer** (the method
absent, or it answered `undefined`): the fallback the contract
prescribes on `getQueryableFields`. Every display field whose
declaration carries a `maskingRule` is passed over, whoever the caller
is. A declaration that cannot be read admits no display field.
- The projection is unchanged. Every display field is still returned in
each row, and the engine still masks the masked one.

File surface: `packages/rest/src/rest-server.ts`, the picker handler's
key selection only (the context moved above it, unchanged). Pins beside
it, one dogfood pin, and `.changeset/21062-picker-queryable-key.md`.

## Per case, by class (measured on real boots)

`bootStack` with the real security, REST, auth and SQL layers, before at
`d9b2e34fac` (the pins commit, the route as on `main`) and after at
`2fa3a86e9b`. Three deployments: no guest set registered; a guest set
without the capability the rule names; a guest set holding it. The
readings stay in the seat's private scratch.

| Picker | Caller class | Before: status, key | After: status, key |
|:--|:--|:--|:--|
| First display field masked | visitor with no session, no guest set
registered (resolves no set) | 403 `PERMISSION_DENIED` on every request;
the masked field | 200; the next queryable display field; masked field
served masked |
| First display field masked | visitor, guest set without the capability
| 403 `PERMISSION_DENIED` on every request; the masked field | 200; the
next queryable display field; masked field served masked |
| First display field masked | signed-in caller the rule applies to |
403, the same as the visitor | 200, the same as the visitor |
| First display field masked | visitor, guest set holding the capability
(rule lifted) | 200; the first display field; served stored | unchanged
|
| Masked field not first (the dogfood shape) | every class above | 200;
the first display field; masked where the rule applies | unchanged |
| No masked display field (control) | every class above | 200; the first
display field | unchanged |
| Only display field masked | the two classes the rule applies to | 403
`PERMISSION_DENIED`, from the engine | 403 `PERMISSION_DENIED`, from the
route before the engine is asked; same refusal text |

The signed-in caller reads the same as the visitor in every row, before
and after: the door builds its own context and does not read the
session.

**What a search finds (H4).** For a picker whose first display field is
masked, every search was refused before. After, a search matches the
next queryable display field, so a caller finds rows by that field. A
search for the masked field's value finds no row. In every other row of
the table the search matches the same field as before, and what a caller
finds is unchanged.

## Pins, red then green

- `packages/rest/src/public-form-lookup-picker-queryable-key.test.ts`
(new). The real `SecurityPlugin` over a real `ObjectQL` on a real
`SqlDriver`, and the real protocol `findData`, so the request the route
composes crosses the real ingress and the engine's own field guards. Two
masked caller classes, each with: the premise (the security service
answers the masked field readable and not queryable, and the engine
refuses it); rows sorted on the next queryable display field with the
masked field served masked; the search matching that field; no request
the engine receives naming the masked field; and a picker with no
queryable display field answering the engine's refusal (code, status and
text equal to the engine's own for the same fields and context) with the
engine never asked. Controls: a picker with no masked display field, and
a caller the rule is lifted for. The fallback: a security service
without the method, and one answering `undefined`, for a caller the rule
applies to and for one it is lifted for. The declared narrowing (patch
round 2): the same fallback service, a caller the rule is lifted for,
and a picker whose display fields all declare a masking rule, refused
`403 PERMISSION_DENIED` with the engine never asked; its control is the
answering service, which serves that caller. The pickers are parsed by
the spec's own `FormFieldPublicPickerSchema` first.
- `packages/qa/dogfood/test/public-picker-queryable-key.dogfood.test.ts`
(new). A real boot: the visitor with no session and a signed-in caller
are served rows sorted, and searched, on the next queryable display
field with the masked field served masked; a picker whose only display
field is masked answers `403 PERMISSION_DENIED`.
- Red on the pins commit `d9b2e34fac`: unit 12 failed, 6 passed (both
masked classes on sorted, searched, never-a-key and the no-queryable
refusal, and the four fallback cases; each failure on the defect: a 403
where 200 was expected, the masked field as the order key, the engine
asked, the masked order). Dogfood 1 of 1 failed (403 where 200 was
expected).
- Green on the fix commit `2fa3a86e9b`: unit 18 of 18; the four sibling
picker and public-form suites 69 of 69; `@objectstack/rest` rebuilt and
the dogfood pin 1 of 1.

## Ablation

On the fix commit `2fa3a86e9b`, through `scripts/ablation-replace.mjs`
in wrap mode: the queryable branch of the key expression (the first
display field in the security service's answer) is replaced by the first
display field, so the key is `displayFields[0]` again on every path.
Anchor 1 to 0, replacement 1 to 2, blob `e2f14974` to `d520d9ab`.

- **Prediction**, written down before the run: exactly the 12 unit pins
that were red on the pins commit go red and the other 6 stay green; the
dogfood pin goes red.
- **Unit pins:** 12 failed, 6 passed. They are the same 12 as the red
run, compared by name.
- **Rebuilt** `@objectstack/rest`; `ablation-dist-preflight --absent`
passed (the marker is in none of the 6 built files; on the pristine
build the same marker was in 2).
- **Dogfood pin:** 1 of 1 failed, on a 403 where 200 was expected.
- **Restore:** `git checkout HEAD --` on the absolute path, by the tool
and again by the script's own `EXIT INT TERM` trap. Proven
byte-identical: the blob equals HEAD's (`e2f14974`), `git diff HEAD` is
empty and the status is clean. Rebuilt; the marker is back in 2 built
files; unit pins 18 of 18 and the dogfood pin 1 of 1.
- **A first attempt was a no-op and is not counted.** Its replacement
text already sat inside its anchor, so the replacement count did not
rise and `ablation-replace` refused to run anything. The anchor was
narrowed and the ablation repeated; the readings above are the second
attempt's.

## Local verification

At the merged head `cdeed45868`. `origin/main` was merged twice (no
rebase) before opening this PR, and both merges were clean. They brought
three changes to `rest-server.ts`, all outside this handler: one import
line (objectstack-ai#21084), the published-snapshot doors (objectstack-ai#21002) and the datasource
metadata reads (objectstack-ai#21119). The changed packages' closure was rebuilt
first.

- Picker and public-form suites with the new pins: 5 files, 87 of 87.
Dogfood, this pin and the objectstack-ai#20995 zero-set pin: 2 files, 3 of 3.
- `@objectstack/rest` tests (`--project local`): 250 files, 4744 passed,
248 skipped. `@objectstack/rest` typecheck passed, the test layer
included (`check:test-typecheck` OK, 0 errors).
- `@objectstack/dogfood` typecheck passed. Its tsconfig includes
`test/**/*`, so the new pin is compiled.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`: 66 commands, plus the three roster families
it marks under a directory this diff touches (`check-changeset-fixed`,
`check:error-code-casing`, `check:filter-alias-parity`) and
`check-partof-closing-keyword` fed this body. All 77 ran in one locked
sequential script and every one exited 0. `--ran` reconciliation: 66
derived, 66 run, 0 not measured.
- `eslint --no-inline-config --format json` on the 3 touched TypeScript
files: 3 files, 0 errors, 0 warnings. The population is
`eslint.config.mjs`'s TypeScript and JavaScript glob, and all 3 files
are in it. The config enables no type-aware linting, so this diff cannot
move the verdict on any untouched file. The repo-wide `pnpm lint` is
left to CI.

## Acceptance notes

- **Clause-②: `yes (narrowing)`, per the contract review**
(`5929549767`, which ruled the earlier `no` wrong). Widening: a picker
whose first display field is masked for its caller was refused and is
now served. Narrowing: for a security service that lacks
`getQueryableFields`, a caller the masking rule is lifted for, and a
picker whose display fields all declare a masking rule, the request was
served and is now refused `403 PERMISSION_DENIED`, by the fallback the
service contract prescribes. The only security service in the tree
(`plugin-security`) implements the method, so no in-tree composition
reaches the narrowing; the contract names that composition as valid, so
it is declared. Patch round 2 carries the `minor` changeset and its pin.
- **The spec describe on `FormFieldPublicPickerSchema.displayFields`**
says the visitor's search matches the first entry. For a caller whose
masking rule applies to the first entry it now matches the first entry
that caller may query; before this PR that search was refused.
`packages/spec/src/**` is outside this PR's claimed surface, so the
describe is reported to the seat for the spec owner and is not edited
here.
- **A declared `publicPicker.filter` row that names a masked field** is
still refused `403 PERMISSION_DENIED` for every caller the rule applies
to. It is the author's filter, not the route's key, so this PR leaves it
alone, and it was measured unchanged. Reported to the seat, by class.
- **The refusal text is the engine's predicate-guard sentence, carried
into the route** so that a caller reads the same words whichever side
refuses. The unit pin asserts it equal to the engine's live refusal for
the same fields and context, so the copy cannot drift silently.
- **Not measured:** a security service without the method on a real boot
(the tree has none); the published-snapshot door and the form-submit
route, which are outside this surface; the Console's picker client,
which lives in another repository. CI's whole-root and workflow-valued
families (10 and 6), the 5 path-scheduled CI jobs and the 4 type-check
lanes the derivation names run on this PR.

## Patch round 1

CI's `Test Core (3/6)` was red on this PR's own change:
`@objectstack/rest#test:repo` failed in
`packages/rest/src/rest-server-canonical-query-ast.test.ts`. That test's
by-name presence pin (section 1, "names them") still asserted the
picker's query literal ordered by `displayFields[0]`. The local runs had
used `vitest --project local`, which does not run the repo-test project.

- **Reproduced first** at `cdeed45868`, under the verify lock:
`test:repo` 1 failed, 178 passed, 1 skipped (5 files). The one failure
is that pin.
- **The pin's purpose is kept.** It is a backstop for the census: it
proves the server-built literals are still present, in the canonical
QueryAST spelling. It now names the picker's literal as it is, `orderBy`
on the handler's one key. It also names the line that derives that key
from the security service's queryable answer, so no other `key` in the
file can satisfy it. `rest-server.ts` is untouched in this round
(`d815dd449a`).
- **Full `@objectstack/rest` surface** at the merged head `6dacc4c51b`
(`origin/main` merged, no rebase, no conflicts): every project together,
256 files (251 local and 5 repo, which is the whole tracked population),
4925 passed and 249 skipped; `test:repo` on its own, 5 files, 179 passed
and 1 skipped; typecheck OK, the test layer included. The dogfood picker
and zero-set pins: 3 of 3. Dogfood typecheck OK.
- **Gates:** the 66 derived commands, the three roster families and
`check-partof-closing-keyword`, in one locked script; every exit 0.
`--ran` reconciliation: 66 of 66, 0 not measured. eslint on the 4
touched TypeScript files: 0 errors, 0 warnings.
- **CI on `6dacc4c51b`:** 32 success, 3 skipped, 0 failed, all six `Test
Core` shards included.
- **Tooling note, outside this diff:** the `turbo` version `main` now
pins writes an agent-guidance block into `AGENTS.md` when it runs in an
agent session. It happened twice here: during the closure build, and
from a type-check gate that spawns `turbo`. Both times `AGENTS.md` was
restored from `HEAD` with the blob proven equal, and the gates the write
could have reached were re-run on the clean tree. `AGENTS.md` is not in
this PR. Already filed as objectstack-ai#21146, with the fix in flight as PR objectstack-ai#21151.

## Patch round 2

The contract review (`5929549767`) ruled the earlier `Clause-②: no`
wrong. The value is `yes (narrowing)`, at least `minor`. This round
carries its items 2 and 3 and nothing else. `rest-server.ts` is not
touched.

- **Changeset** `.changeset/21062-picker-queryable-key.md`:
`'@objectstack/rest': minor`; the summary is `fix(rest)!:`; a
`**BREAKING**` banner names the narrowing by class (with a security
service that lacks `getQueryableFields` or answers no answer, every
display field declaring a `maskingRule` is passed over for every caller,
so a picker whose display fields all declare one is refused `403
PERMISSION_DENIED` without the engine being asked); the body's
declaration line reads `Clause-②: yes (narrowing)`; exactly one
`adr-0087:` marker, `not-required (no-migration-prescription)`, in the
shape of objectstack-ai#20986's changeset, closing the other four categories on facts.
The widening text is kept. The fallback, which round 0 listed under
"Unchanged", moved to the banner, and a "What to do after upgrading"
paragraph addresses a deployer whose security service predates
`getQueryableFields`. `scripts/pm/clause2-line.mjs` reads the line as
declared `yes`, arm `narrowing`.
- **Pin on the declared narrowing**, appended to
`packages/rest/src/public-form-lookup-picker-queryable-key.test.ts`,
with the existing synthetic fixtures. A caller the rule is lifted for is
served by the answering service (the control). With the method absent,
and with it answering no answer, the same caller is refused `403` with
code `PERMISSION_DENIED`, and the engine's call count is unchanged.
- **Ablation:** the fallback stops excluding fields that declare a
masking rule, through `scripts/ablation-replace.mjs` in wrap mode
(anchor 1 to 0, replacement 0 to 1). The prediction was written down
before the run: the new pin and the four round-0 fallback pins go red,
because they drive the same branch; the other 14 stay green. Observed: 5
failed, 14 passed, those five by name; the new pin failed on a 200 where
the 403 was expected. The restore was proven by the tool (blob equal to
`HEAD`'s, `git diff HEAD` empty), and the pin file is green again, 19 of
19.
- **Merged** `origin/main` first (no rebase, clean; no incoming change
to `rest-server.ts`). Head `207eb98a97`.
- **`@objectstack/rest`:** all vitest projects, 257 files (the whole
tracked population, local and repo), 4931 passed and 254 skipped;
`test:repo` on its own, 5 files, 179 passed and 1 skipped; typecheck OK,
the test layer included. Dogfood picker and zero-set pins 3 of 3;
dogfood typecheck OK.
- **Gates:** in one locked script, the changeset families against the
new declaration ran first: `check-adr-0087-registration` reads
`[BREAKING+bang+clause-②-narrowing]` with `not-required
(no-migration-prescription)`; `check-changeset-no-major`,
`check-empty-changeset` and `check-changeset-fixed` passed. Then the 66
derived commands, the three roster families,
`check-partof-closing-keyword` fed the live body, and eslint on the 4
touched TypeScript files (0 errors, 0 warnings). All 81 commands exited
0. `--ran` reconciliation: 66 of 66, 0 not measured. Locally
`check-changeset-no-major`'s level axis has no PR payload to read and
says so; CI's `Check Changeset` reads it, and it passed.
- **`turbo` and `AGENTS.md`:** `turbo` wrote its block into `AGENTS.md`
during the closure build and during `check:type-check-debt`. The script
restored it from `HEAD` after each command and proved the blob, so every
later command ran on the clean tree. `AGENTS.md` is not in this PR.
- **CI on `207eb98a97`:** 35 check runs, 32 success, 3 skipped (path and
opt-in filters), 0 failed. Every required context is success, `Check
Changeset` and all six `Test Core` shards included.

_The seat appended this section from the dev's patch-round report
(`5930061042` on objectstack-ai#21062), and extended the pin-file line above with the
narrowing pin._

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…om the shared persistent sequence (objectstack-ai#21113) (objectstack-ai#21160)

Fixes objectstack-ai#21113
Clause-②: yes (widening)

## Patch round 1 (review 5930100091)

The one blocking item was `@objectstack/driver-sql` at `patch` beside a
root `type` re-export (`AutoNumberReservation`). Branch taken: the
re-export is dropped and the two re-seed overrides in `turso-driver.ts`
are typed `Parameters` indexed over `SqlDriver`'s
`autoNumberValueExists` (its second parameter) and
`resyncSequenceToDataMax` (its first) — the premise (the protected
member's parameter type is reachable from the subclass without the
export) measured true: `driver-sql` typecheck exit 0, `driver-turso`
typecheck exit 0, no structural copy. `driver-sql`'s export list is
byte-identical to `main`, so `patch` stands; the changeset sentence that
named the export now reads "No export is added". `origin/main` merged
again (`fde553c50`, which also lands the `agentGuidance: false` opt-out
of objectstack-ai#21151); re-run on the merged tree: closure build exit 0, both
typechecks exit 0, `driver-turso` `82 passed (82)` files / `2218 passed
| 33 skipped` (83 files / 2219 with the throwaway probe present),
`driver-sql` autonumber suites `66 passed | 2 skipped`, and the
changeset, ADR-0087, nul-bytes and published-files gates all exit 0.
Finding (b) was measured on the remote face as the review asked and
reproduces; it is in the round-1 report on objectstack-ai#21113 for the seat to file.

## What this does

Disposition A of the remote-autonumber question, executed: a `create`,
`bulkCreate` or `upsert` on the Turso REMOTE transport that leaves an
`auto_number` slot empty now gets a generated value, issued atomically
in the database from the same persistent `_objectstack_sequences`
counter the local and embedded-replica faces draw from.
`supports.autonumber` stays `true` on the remote face and is now
honoured. The `NOT_IMPLEMENTED` / 501 refusal is gone; its suite is
converted into the generation suite.

Why now: the appetite door the refusal left shut (「for want of measured
demand」) was met by objectstack-ai/cloud#2531 — `POST
/api/v1/data/crm_account` answers `501` on a hosted tenant, whose
database is on this transport, so no object declaring an `auto_number`
field could get a new record on the hosted product. That card carries
the cloud half, `Blocked-by:` this one.

## How, and what is shared rather than copied

**Where the slots are filled.** On the driver, one layer above the
statement builder, exactly where the refusal was raised:
`RemoteTransport.create(object, data)` takes no schema and caches none
(pinned), while `TursoDriver` holds `autoNumberFields` from remote
schema sync. The remote `create` / `upsert` / `bulkCreate` call the
inherited `SqlDriver.fillAutoNumberFields` on a copy of the caller's
row, then hand the filled row to the transport, which builds the same
`INSERT` it built for a caller-supplied number.

**One semantics (H3).** The rules that decide WHICH counter a value is
drawn from and WHERE a cold counter starts are `SqlDriver`'s and are
called, never copied. Lifted into `protected` members in the sequence
region of `sql-driver.ts` (`:7532`–`:8190` on this head; nothing else in
that file): `resolveSequenceTenantId` (replaces three inline copies),
`defineSequencesTable` (the table's one definition — the remote face
compiles it to text with the connection-less Knex it already holds and
sends that), `maxAutonumberCounter` + `escapeLikePrefix` (the bootstrap
reading, suffix included, split from the Knex statement that fetches the
values), `sequencesTableName`, `autoNumberCollisionRetries`. The two
re-seed overrides take their parameter types through `Parameters`
indexed over `SqlDriver`'s protected members, so `driver-sql`'s export
list is unchanged. The format rendering and precedence were already
shared (`resolveAutonumberFormat` at registration, `renderAutonumber` /
`missingFieldValues` in `fillAutoNumberFields`), and are reached by
calling that method.

**What is this face's own: how the counter moves (H2).**
`TursoDriver.getNextSequenceValue` routes by transport. Local and
replica keep the inherited Knex transaction. Remote moves the counter in
ONE statement:

```
warm:  UPDATE _objectstack_sequences SET last_value = last_value + 1 WHERE key_hash = ? RETURNING last_value
cold:  scan the data table's MAX in JS by the shared reading, then
       INSERT … VALUES (…, max + 1) ON CONFLICT (key_hash) DO UPDATE SET last_value = last_value + 1 RETURNING last_value
```

Each is a single SQLite statement serialised by the database's write
lock across connections and processes; no in-process state takes part
(`remoteSequencesTableEnsured` remembers only that the TABLE exists).
Two cold writers both scan and both INSERT; one wins the row, the
other's `ON CONFLICT` arm increments it.

H2 falsified on one point, reported rather than followed: the PM
expected the seed folded into the statement as a SQL `MAX(…)`. It is
not, because the bootstrap reading strips a declared suffix and reads
the digit run after the prefix (`readAutonumberCounter`, objectstack-ai#6468), which a
dialect expression would be a second copy of — the exact collision H3
forbids. A Hrana batch was not needed either: the JS reading has to sit
between the scan and the insert, and both insert arms are atomic on
their own.

**The legs (H4).** `create`, `bulkCreate` and `upsert` all generate.
`bulkCreate` on the remote face now runs through the driver's own
`create` per row, which is what `RemoteTransport.bulkCreate` always did
underneath (it loops the transport's `create`: one INSERT and one
read-back per row, never one statement — so the old comment's
「all-or-nothing on this transport too」 was not true, and the statements
sent and the mid-batch failure state are unchanged). `upsert` reserves
before the statement, as `SqlDriver.upsert` does, and names the
autonumber columns to the transport as insert-only
(`RemoteTransport.upsert` gains an optional fifth argument, schema-free:
WHICH columns, not WHY), so a merge keeps the number already in the row
(objectstack-ai#7011) and the insert leg — the one objectstack-ai#7099 recorded as writing NULL —
gets its number. The reservation a merge does not use is a gap, never a
renumbering, the local face's rule byte for byte (pinned: seeded 42,
merge, next create is 44). `objectstack-ai#7099`'s post-write warning is removed with
the leg it reported.

**Legacy shape and collision re-seed (H5), decided from the code.** The
pre-`key_hash` table: `SqlDriver.ensureSequencesKeyHashShape` rebuilds
it through a live Knex connection, which this face has none of, and a
raw-SQL rewrite would be a second copy of a migration; keying by the
legacy `(object, tenant_id, field)` rule would be a second keying rule
beside the shared one. So it is **refused**, `DATABASE_ERROR` / 500 with
the remedy in the message (open the database once through the local or
replica face), not cached as a verdict, caller-supplied numbers still
written. It cannot arise on a database this face created. The objectstack-ai#5495
re-seed is **needed** on this face — the bypass paths that create a
stale counter (`isSystem` seed replay, `preserveAudit` import) are
exactly the seed/import paths, and `bulkCreate` is their common door —
and it is carried: `autoNumberValueExists` and `resyncSequenceToDataMax`
are overridden for the remote face (the forward-only move is one atomic
UPDATE guarded by `last_value` being below the observed MAX), and
`collidingAutoNumberReservations` is the inherited discriminator, so a
duplicate on a value the caller typed stays the caller's 409 (pinned).

**H6.** `Clause-②: yes (widening)` holds as measured: a create refused
501 today is accepted; no spec key moves; no export is added (the six
lifted members are `protected`, and the overrides are typed through
indexed types rather than a root re-export); `RemoteTransport.upsert`
gains an optional parameter. Changeset: `@objectstack/driver-turso`
minor, `@objectstack/driver-sql` patch.

## Tests

Head for every number below: `52ab7ad76` (the merge of `origin/main`
`1bd14c984`), unless stated.

- **Converted, not deleted:** `turso-remote-autonumber-refusal.test.ts`
→ `turso-remote-autonumber-generation.test.ts` (`git mv` then rewritten;
git's rename detection does not fire at this similarity, so the diff
reads D+A — the three-face structure, the controls, the `update()` /
plain-object / caller-supplied cases and the layer pins are the refusal
suite's own). 27 cases: generation on every leg incl. the objectstack-ai#7099 leg;
caller-supplied numbers, merge, update, plain object untouched; the
two-face block (local Knex face and remote native-libsql face on ONE
file: numbers interleave 1,2,3,4 on one `key_hash` row equal to the
local face's `sequenceKeyHash`; per-tenant buckets; the objectstack-ai#6468 suffix
seed read as 5 not 52026; the `{field}` refusal is the same sentence on
both faces and writes nothing); the objectstack-ai#5495 re-seed on `create` and
`bulkCreate`; the caller's own 409; the legacy-shape refusal with `code`
+ `status`; local and replica still issue; `RemoteTransport.prototype`
still has no autonumber member and `create.length === 2`;
`supports.autonumber === true`.
- **The two boundary pins rewritten, not deleted**
(`turso-autonumber-resync.test.ts`,
`turso-autonumber-batch-resync.test.ts`): the transport-surface probe
verbatim, and the refusal half replaced by generation + re-seed on the
remote face.
- **Cross-process pin** `turso-remote-autonumber-concurrency.test.ts`:
two child processes (`tsx` over the package source, as `packages/spec`'s
process-boundary pins do; `tsx` added as a devDependency of
`driver-turso`), each with its own `@libsql/client` native `file:`
connection to one database file and its own remote-mode `TursoDriver`,
released together by a file barrier; 2 × 200 creates per round → 400
distinct, exactly 1..400, strictly increasing per writer, no failed
write, the table and the counter row agreeing. Those are the hard pins,
asserted on every round. The overlap EVIDENCE (the writer changes at
least twice in the global order) ends the loop on the first round that
shows it; after three rounds without one the test is skipped with a note
that says NOT MEASURED — because whether two processes overlap is the
scheduler's decision, measured here once as a strictly serial run (1
change) under another seat's full-package run on this shared box.
Final-head runs: 3/3 green standalone, measuring `400 writes · 400
distinct` with 31, 21 and 23 writer changes; inside the full package
run, 9. **Said plainly:** no `sqld` runs in this container; what is
measured is two processes on libSQL's engine under its write lock, not
an HTTP server.
- Driver-conformance ledger (lane commitment): identical before and
after — `50 covered cell(s), 0 in the DEBT ledger, 0 exempt`; the matrix
has no autonumber cell for any driver, so none moved.

Runs, all under `scripts/pm/os-verify-lock.sh` after the merge: `pnpm
--filter '@objectstack/driver-turso^...' build` exit 0 · `driver-sql`
typecheck exit 0 · `driver-turso` typecheck exit 0 · `driver-turso`
`vitest run`: `82 passed (82)` files, `2218 passed | 33 skipped (2251)`
· `driver-sql` `vitest run` in two halves of 108 files: `103 passed | 5
skipped`, `1389 passed | 100 skipped`; `102 passed | 6 skipped`, `1914
passed | 88 skipped` (every file; the autonumber-named nine also run on
their own: `66 passed | 2 skipped`).

## Reverse verification (predicted before each run; mutation and restore
proven on disk by `scripts/ablation-replace.mjs`, blob == HEAD and `git
diff HEAD` empty after each leg)

1. **Merge-set exclusion dropped** (`RemoteTransport.upsert` ignores
`insertOnlyColumns`). Predicted: exactly the two `[objectstack-ai#7011]` merge pins
red. Measured: `2 failed | 25 passed`, both merge pins, `expected
'CASE-00043' to be 'CASE-00042'` and `expected 'CASE-00099' to be
'CASE-00042'` — the renumbering the exclusion exists to prevent.
2. **Warm path made non-atomic** (SELECT `last_value`, then UPDATE to
`+1`). Predicted: the distinct count in the concurrency pin drops below
N; the stub-backed generation suite stays green. Measured — **direction
reversed, kept rather than tidied**: the generation suite stayed green
(`27 passed`) as predicted, but the concurrency pin went red on its 「no
write failed」 assertion, not on the distinct count: the fixture's
tenant-scoped unique index refused the second row carrying a duplicated
number, and the forward-only re-seed could not outrun a writer that kept
reading the same stale value, so writes failed with `SQLITE_CONSTRAINT:
UNIQUE constraint failed: index
'uniq_crm_case_organization_id_case_number'` — 2 of 3 runs with no gap
between the two statements, 3 of 3 with a 1 ms gap (the order of one
HTTP round trip). On a declared-unique column a counter that hands out a
number twice surfaces as refused writes before duplicate rows; recorded
in the pin's docblock.
3. **Shared bootstrap reading zeroed** in `driver-sql`
(`maxAutonumberCounter`, reached through `dist/`: rebuilt,
`ablation-dist-preflight` present in 2 built files, exit 0). Predicted:
the bootstrap and re-seed pins red on BOTH faces. Measured: `10 failed |
25 passed` across the three suites — generation:
explicit-then-continues-above, merge-then-44, objectstack-ai#6468 suffix, re-seed
`create`, re-seed `bulkCreate`; resync: LOCAL and REMOTE re-seed;
batch-resync: LOCAL `bulkCreate`, LOCAL `upsert`, REMOTE batch. Restore:
rebuilt, preflight `--absent` exit 0. A first attempt of this ablation
was a null operation (the tool refused because the replacement restated
the anchor; the preflight correctly reported the marker absent and that
green run is void) and was redone with a different anchor.

## Gates

Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` on `52ab7ad76` (no paths; 73 commands). Run
on the final tree (`2c9d2985a`, every file identical to the commit): the
73 derived commands — **72 exited 0**, incl. `check:driver-conformance`,
`check:query-options-erasure` (holds: 67 unswept non-test sites, none
new — my first head had added one `as any` on a `find()` query in the
new concurrency pin, retyped as `DriverQuery`),
`check:test-source-alias`, `check:undeclared-dep-imports`,
`check:doc-authoring`, `check:nul-bytes`, `check:lean-entry-closure`
(measured after building `@objectstack/objectql`: 15 packages, 201/199
modules, admitted set held), `check:type-check-debt` (399.5 s
re-measure, none above record), `check:pm-skill-ratchet` (0 with
AGENTS.md at the merge-base blob — the turbo 2.11.5 agent-rules block,
objectstack-ai#21146, had rewritten it once in this worktree and is restored).
`check:dts-closure` exited 1 on its first final-tree run naming
`plugin-email` and `plugin-security` (neither in this diff nor in its
closure) with `dist/index.d.ts` absent at that moment; a forced rebuild
of the two and a rerun exits 0 (71 packages, 167/167 declarations) —
concurrent turbo activity in the same tree, not this change. **NOT
MEASURED (1):** `check:dual-build-cjs-loads` exits 3 PREREQUISITE NOT
MET locally (78 packages without `dist/` in this worktree) — CI's.
`dispatch-gates --ran`: 73 derived, 72 run, 1 NOT-MEASURED declared, 0
UNRUN (exit 0). Seven further families (`agent-test-spelling`,
`docs-audit-scope`, `pm-governed-merges`, `pm-governed-prose`,
`pm-skill-id-lint`, `pm-skill-ratchet`, `required-contexts`) were
derived only while the turbo block sat in AGENTS.md; they were run
anyway and all exit 0. The derivation itself still lists what no local
run covers: the 53 artifact-roster families, 11 wide-population
families, 6 path-scheduled CI jobs and the type-check lanes.

`pnpm lint` narrowed, as a measurement: ① population = the lint script's
own `eslint . --no-inline-config`, under one `eslint.config.mjs`; ②
`--format json` on the changed source files: 9 files, 0 errors, 0
warnings; ③ invariance: the config enables no type-aware linting for any
file (`eslint.config.mjs` states it, no `parserOptions.project`), so
this diff moves no untouched file's verdict.

## Acceptance notes

- Both faces: SQLite's `LIKE` has no escape character unless `ESCAPE` is
declared, and the shared `escapeLikePrefix` writes backslashes. The
remote statement declares `ESCAPE '\'`; the local Knex builder in
`scanMaxNumericTail` does not (knex emits `like ?` with the escaped
binding and no `ESCAPE`, measured on the sqlite and pg dialects), so on
the local SQLite faces a format whose prefix contains `_` or `%` seeds
from 0 (measured: `SO\_%` matches nothing on SQLite without `ESCAPE`).
Pre-existing, outside this card's class; filed in the report as a
finding, not fixed here.
- `RemoteTransport.upsert` with caller `conflictKeys` leaves `id` in the
merge set (`"id" = excluded."id"`) — the objectstack-ai#8622 shape the local face
closed. Not touched; noted for the lane.
- The README's remote refusal table loses its 「Record numbers」 row;
`content/docs/**` carries no statement of the remote refusal (grepped).

## 维护者速读(草稿)

- **改了什么:** Turso 远程传输面上,`create` / `bulkCreate` / `upsert` 留空的
`auto_number` 字段现在会生成编号,编号来自与本地面相同的持久计数表,在数据库里原子递增;原先的 501 拒绝改为生成。
- **为什么改:** 托管产品的租户库全部走远程传输,任何声明了 `auto_number` 字段的对象都无法新建记录(cloud#2531
实测:HotCRM 建客户答 501)。这正是 objectstack-ai#6944 当初留下的「等实测需求」的门,现在门开了。
- **风险与代价(含回滚):**
多进程并发只靠数据库写锁串行,无进程内计数;两个面共用一张计数表、一套键规则,已在同一数据库文件上对拍。遗留形状的计数表在远程面上明确拒绝、不迁移。回滚即还原本
PR;已生成的编号留在数据里,计数表形状与本地面一致。
- **席位意见:** (留空)
- **你要做的:** 批准后 cloud#2531 推进 pin 并在托管环境复验建客户。

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…and lints again — the `live` ledger row carries no author warning (objectstack-ai#21127) (objectstack-ai#21176)

Fixes objectstack-ai#21127

Clause-②: no

## What this changes

`os validate --json` and `os lint --json` exited 1 on any stack whose
`mappings[]` entry authors `connectorSource`. The whole answer was the
liveness lint's integrity sentinel: `lintLivenessProperties: ledger
entry has unrecognised status "live"`. The ledger row
`mapping.connectorSource` had been re-graded `live` and kept
`authorWarn: true`. `describe()` in
`packages/lint/src/lint-liveness-properties.ts` throws on a warned
`live` row by design, and that throw stays as it is.

- **The row** (`packages/spec/liveness/mapping.json`): `connectorSource`
stays `live` and drops `authorWarn` / `authorHint`. Its `note` records
why.
- **The caveat moves to where an author reads it**
(`packages/spec/src/data/mapping.zod.ts`). The `connectorSource`
`.describe()` now reads: "Pulled when a job drives it; nothing schedules
it yet, so the binding alone moves no rows — schedule the pull with a
`job` once a job can drive one". The docblock no longer says the ledger
keeps `authorWarn`.
- **The existing integrity check refuses the combination, inside its
current gate** (`check:liveness`,
`packages/spec/scripts/liveness/check-liveness.mts`). No new gate or
script was added. The graded walk never reads a container row that
drills into `children`, and `connectorSource` is such a row, so the new
rule walks the raw ledger rows at every depth. It fails on `status:
"live"` with `authorWarn: true`, prints a prescription, and prints a
census line on every run: `author warnings: 2 ledger row(s) opt into
authorWarn, at any depth (planned 2); 0 on a live row.`
- **Regenerated:** `content/docs/references/data/mapping.mdx` and
`integration/connector.mdx` (`gen:docs`), and
`packages/spec/src/migrations/registry.ts` (`gen:migration-registry`).

## Files beyond the claim's listed surface (each one is text this change
made false)

The claim lists the ledger row, the `connectorSource` describe, the
existing integrity check, the regenerated artefacts, the pins and one
changeset. These files are outside that list. Each one said that
authoring the binding warns, and that stopped being true with the row
fix:

- `packages/spec/src/integration/connector.zod.ts`: the retired
`connector.syncConfig` prescription, which an author sees in the
refusal.
-
`packages/spec/src/migrations/entries/semantic/18.connector-sync-keys-retired.ts`:
the upgrade entry's acceptance text. `registry.ts` is regenerated from
it.
- `packages/spec/src/integration/connector-sync-retirement.test.ts`: a
one-line pin on that prescription text.
- `packages/spec/docs/SYNC_ARCHITECTURE.md`: "authoring
`connectorSource` still warns".
- `packages/spec/liveness/README.md`: the mapping row's note, plus a
third `authorWarn` rule (a `live` row carries none).
- `packages/lint/src/authoring-rules.ts`: comments only.
- `packages/lint/src/runtime-gate.inert-type-writes.test.ts`: a comment,
and a pin. A `mapping` write that authors `connectorSource` used to get
an `authoring-rule-threw` advisory at the runtime door, and now gets
none.
- `packages/cli/test/validate-lint-mapping-connector-source.test.ts`:
the door pin, a new file.
- `.changeset/20919-spec-connector-source-live.md`: see the next
section.

## ⚠️ One deliberate correction of a pending release note (needs
confirming)

`.changeset/20919-spec-connector-source-live.md` belongs to PR objectstack-ai#21084.
It has not been released yet, and it said "`connectorSource` rows are
`live` and keep `authorWarn`". This PR makes that false, so the sentence
now reads "rows are `live`, with no author warning: that nothing
schedules a pull yet is said on the key's description".

`node scripts/check-empty-changeset.mjs --base origin/main` is therefore
red by design. It says: "This PR changes a changeset it did not add …
DELIBERATE CORRECTION -- … do NOT restore it -- say so on the PR and get
it confirmed". This section is that statement, and `skip-changeset` is
not applied.

`.changeset/20281-connector-sync-moved-to-mapping.md` carried a similar
claim. PR objectstack-ai#21150 has since rewritten it on `main` without the warn
claim, and this PR does not touch it.

## Premise check

- **H1, the crash** (measured at the dispatch base `665cab338f`, which
contains PR objectstack-ai#21092 `b616c0a63d`). The card's fixture is one object
`fx_account` with `sharingModel: 'private'`, plus one mapping with
`sourceFormat: 'json'`, `targetObject: 'fx_account'`, `mode: 'upsert'`,
a `fieldMapping` and `connectorSource: { connector: 'crm_api', action:
'request' }`.
- At base, `os validate --json` and `os lint --json` both exited 1, and
the only error was the sentinel.
- After the fix, on the merged head `b6d58dd04`: `validate` exits 0 with
`"valid": true`. `lint` exits 0 with `"passed": true` and one unrelated
warning (`protocol/missing-engines-range`). Neither output names
`connectorSource` or contains the sentinel.
- The crash had been hiding a real finding. Without `sharingModel`, the
same object gets `security-owd-unset`, so the fixture declares it.
- **H2, the integrity check.** Yes, inside `check:liveness`. Run against
the unfixed ledger, the gate exits 1 with exactly one ✗ block: "1 `live`
ledger row(s) opt into `authorWarn` — the author-side lint throws on
them: mapping/connectorSource".
- **H3, census** (re-run on the merged head). Across all 41 governed
ledgers, 924 rows were walked at every depth. Two carry `authorWarn:
true`, both `planned` (`object.externalSharingModel`,
`translation.flows`). None is `live`. Before the fix,
`mapping.connectorSource` was the only `live` one, so no other row has
this defect.
- **H4, describe text.** Done as above. Author-facing text cites no
tracker number.

## Pins

- `packages/spec/src/data/mapping-connector-source.test.ts`: every key
of the binding is `live`, and no row of it, at any depth, carries
`authorWarn` / `authorHint`. The description still says nothing
schedules a pull. The old assertion `authorWarn === true` pinned the
defect.
- `packages/spec/scripts/liveness/check-liveness.test.ts` runs the real
gate via `--ledger-root`:
- green on the shipped ledgers, with the census line showing warned
`planned` rows and none on a `live` row (the control);
- red when a `live` container row with `children` opts in (the
regression's shape);
  - red when a drilled `live` child opts in.
- The carriers are picked from the shipped ledgers by shape, not by
name.
- `packages/lint/src/lint-liveness-properties.test.ts` runs against the
real ledger. The card's fixture lints without throwing and draws no
`connectorSource` finding. Control: `object.externalSharingModel`
(`planned` + `authorWarn`) still warns as `liveness-planned-property`.
- `packages/lint/src/runtime-gate.inert-type-writes.test.ts`: a
`mapping` write that authors `connectorSource` gets no errors and no
advisories at the runtime door.
- `packages/cli/test/validate-lint-mapping-connector-source.test.ts` is
in the integration tier and spawns the CLI:
- the card's fixture: `os validate --json` exits 0 with `valid: true`,
and `os lint --json` exits 0 with `passed: true`;
- control: adding `externalSharingModel` produces a
`liveness-planned-property` warning, still at exit 0.

## Reverse verification (one-time, not a permanent file)

This was run twice on the committed fix, with identical results: once at
`7bc7c5c26`, and again after the `main` merge at `b6d58dd04`. `node
scripts/ablation-replace.mjs` put `"authorWarn": true` back on the
`connectorSource` row. The anchor count went 1 → 0, and the blob changed
`49cc72e394b8` → `65665b90b21d`. With the defect back, every leg went
red:

- door: `validate` exit 1 and `lint` exit 1, with the sentinel in both;
- `check:liveness` exit 1 (`1 on a live row — FORBIDDEN`);
- spec pins: 2 failed;
- lint pins: 3 failed;
- CLI door pin: 3 failed.

The tool restored the file and proved it: the blob equals HEAD and `git
diff HEAD` is empty. The ledger is read from `packages/spec/liveness/`
at runtime, not from `dist/`, so no rebuild was involved.

## Verification (all at `b6d58dd04`, the PR head)

`origin/main` `fde553c50` was merged in with `bash
scripts/pm/os-regen-merge.sh`. The merge was clean. `registry.ts`
auto-merged as text, and `check:migration-registry` then confirmed it.
Main had not touched the two reference pages, so they kept the branch's
bytes. No regeneration commit was needed: after a fresh spec build,
`check:generated` reports all 15 generated artefacts up to date. The
delta against `main` is unchanged: 19 files, +538 / −42.

| what | command | result |
|---|---|---|
| spec build | `pnpm --filter @objectstack/spec build` | exit 0 |
| generated artefacts | `pnpm --filter @objectstack/spec run
check:generated` | exit 0, "All 15 generated artifacts are up to date" |
| CLI closure build | `pnpm exec turbo run build
--filter=@objectstack/cli... --concurrency=2` | exit 0, 59/59 tasks |
| the card's fixture | `os validate --json` / `os lint --json` | exit 0
/ exit 0, sentinel 0 times, `connectorSource` 0 times |
| ledger integrity | `pnpm --filter @objectstack/spec run
check:liveness` | exit 0, "0 on a `live` row" |
| strictness ledger | `pnpm --filter @objectstack/spec run
check:strictness-ledger` | exit 0 |
| spec tests | `vitest run scripts/liveness src/data
src/integration/connector-sync-retirement.test.ts src/migrations` | exit
0, 130 files, 4269 passed, 1 todo |
| spec typecheck | `pnpm --filter @objectstack/spec typecheck` | exit 0
|
| lint, whole package | `pnpm --filter @objectstack/lint exec vitest
run` + `typecheck` | exit 0, 118 files, 5489 passed; typecheck exit 0 |
| CLI unit tier | `vitest run --project unit test/lint test/validate
src/commands/validate src/commands/lint` | exit 0, 11 files, 125 passed
|
| CLI door pin | `vitest run --project integration
test/validate-lint-mapping-connector-source.test.ts` | exit 0, 3/3 |
| eslint, narrowed | `pnpm exec eslint --no-inline-config --format json`
on all 19 changed paths | exit 0. eslint linted 12 and reported 7 (`.md`
/ `.mdx` / `.json`) as "no matching configuration"; 0 errors, 0 warnings
|
| derived gates | `dispatch-gates.mjs --commands` (117), each run, then
`--ran` | 117 run: 116 exit 0, and 1 exit 1. That one is
`check-empty-changeset --base origin/main`, the deliberate correction
above. `--ran`: "117 run, 0 NOT-MEASURED, 0 UNRUN".
`check:pm-dispatch-gates` was killed by a timeout at 400s and then at
540s. It was then run to completion and exited 0; its battery took 1432s
on this box |

**The eslint narrowing is a measurement, not a skip:**
- The population comes from eslint's own answer for each changed path:
12 linted, and 7 that `eslint.config.mjs` has no configuration for.
- The file count comes from the `--format json` output.
- The config enables no type-aware linting (no `parserOptions.project`),
so this diff cannot move the verdict on any file it did not touch.

## Acceptance notes

- **turbo 2.11.5 wrote into `AGENTS.md` before the merge.** It appended
its managed `turborepo-agent-rules` block on every AI-attributed turbo
run in this worktree: 3 builds, and once during `check:type-check-debt`.
Each time the file was restored with `git checkout HEAD -- AGENTS.md`,
and `git diff HEAD` was confirmed empty. Files were staged by path only,
so the block was never staged and is not in this PR. PR objectstack-ai#21151
(`agentGuidance: false` in `turbo.json`) came in with the merge. Since
then, `AGENTS.md` stayed unmodified across the 59-task CLI closure build
and all 117 gates.
- `@objectstack/lint` gets no changeset. Its source change is comments
only. Measured: tsup keeps comments, so the comment text does reach
`dist/*.js` bytes. The seat can override this.
- objectstack-ai#21127 is cited only where agents and reviewers read: the
`authoring-rules.ts` comment, and the ledger's `note` and README prose.
Author-facing text (`.describe()`, the retired-key prescription, the
upgrade entry) cites no tracker.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…lared JSON-stored field, in the SQL family's words (objectstack-ai#21066) (objectstack-ai#21159)

Fixes objectstack-ai#21066
Clause-②: yes (narrowing)

On a field the object declares JSON-stored (a `multiple: true` field,
`tags` / `multiselect` / `checkboxes`, or a structured-JSON type such as
`json`), `driver-memory` now refuses the scalar-comparison family that
`driver-sql`'s `where` refuses: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`,
`$lte`, `$between`, `$in`, `$nin` and implicit equality, whatever the
comparand, at any depth. The answer is `INVALID_FILTER` / 400 with the
same message. The operator set and the sentence are read from
`@objectstack/core` (`JSON_COLUMN_INCOMPATIBLE_OPERATORS`,
`jsonColumnOperatorRefusalText`, homed by PR objectstack-ai#21097). There is no third
copy. `$contains` / `$notContains` (membership), `$null`, `$exists` and
`$empty` keep answering.

## What was wrong (H1, measured at `origin/main` `670680e93` through
`engine.find`)

A real `ObjectQL` over `InMemoryDriver`, objectstack-ai#21004's six rows (`owners` is
a `multiple: true` lookup, `tags` is a `tags` field). Every row
reproduces the card:

| `where` | before | now |
|:--|:--|:--|
| `owners` `$eq 'u1'` | `d1`, `d3` (per element) | 400 `INVALID_FILTER`
|
| `owners` `$in ['u1','u9']` | `d1`, `d3` | 400 |
| `owners` `$nin ['u1','u9']` | `d2`, `d4`, `d5`, `d6` | 400 |
| `owners` `$gt 'u1'` | `d1`, `d2`, `d3`, `d5` | 400 |
| `tags` `$gt 'red'` | `d3` | 400 |
| also: bare `{ owners: 'u1' }`, `$ne`, `$gte`, `$lt`, `$lte`,
`$between`, `tags $eq`, `{ owners: null }`, `$eq null`, `$ne null`, `$in
[]`, `$nin []` | rows, per element | 400 |
| controls: `owners $contains 'u1'` / `$notContains` / `$null` /
`$exists` / `$empty`, `title $in` | rows | unchanged rows |

The engine hands the driver the operators as written, except `$ne` /
`$nin`. Those arrive inside the spec's null-safe lowering (`$and` of
`$or` of `$null: true` and the operator). The gate walks `$and` / `$or`
/ `$not`, so that shape is refused too.

The analytics face (`MemoryAnalyticsService`) answered the same
per-element rows. Its SQL echo rendered `owners = 'u1'`, which matches
no row over the JSON text the SQL family stores. It now refuses in
`query()` and `generateSql()` alike.

## What changed

- `filter-refusal.ts`: the shape gate (`assertFilterConditionShape`)
takes an optional `FilterFieldDeclarations` (`isJsonStoredField`,
`reportWithheld`). It has two arms. Implicit equality on a declared
JSON-stored field is refused as `=`, bare. Any operator in the shared
set is refused AFTER the existing comparand-shape rules, which is
`driver-sql`'s order (comparand gate, then column-type gate). So an
array under `$eq` or a one-element `$between` still gets its own refusal
first. `jsonStoredFieldOperatorError` builds the error from the shared
text: this package's `unsupportedFilterError` envelope, with the
withheld diagnostic handed to `reportWithheld` (prefixed `At PATH:`)
before the throw.
- `memory-driver.ts`: `convertToMongoQuery` passes
`this.filterFieldDeclarations(object)`. The population is
`isJsonStoredField`, the predicate `$contains` already forks on
(`STRUCTURED_JSON_TYPES` or `isMultiValueField`). So the fields where
`$contains` asks membership are exactly the fields where the family is
refused. The diagnostic goes to the driver's logger at `warn`, the level
`driver-sql` uses for its withheld filter diagnostics. That keeps the
message's "the full diagnostic is in the server log" true here.
- `memory-analytics.ts`: `normalizeFilters` takes the cube. It judges a
`where` key (a cube member) by the field it maps to on the cube's table,
the same (table, field path) pair `filterContainsTest` reads. Its
diagnostic goes to the analytics service's own logger.
- `.changeset/21066-memory-json-column-family-refusal.md`:
`@objectstack/driver-memory` `minor`, BREAKING banner, `Clause-②: yes
(narrowing)`, one ADR-0087 marker `not-required
(no-migration-prescription)`. No registered id covers a filter operator
on a JSON-stored column. The one migration-registry entry that mentions
json columns (`cel-predicate-one-value-comparand-refused`) is the CEL
list-comparand surface, not this one.

## Hypotheses, measured

- **H1** holds: the table above.
- **H2.** The shared home is `@objectstack/core`'s
`json-column-operator-refusal.ts`, and both names are read. Before this
change `driver-memory` had NO withheld-diagnostic seam: every refusal it
raises (the `$null` / `$exists` non-boolean refusals included) names the
field in the message, and nothing in the package logged a diagnostic.
This change keeps the shared posture: the message names neither field
nor operator, and the diagnostic goes to the server log.
- **H3.** `driver-sql` decides a JSON column from `jsonFields`, filled
from `JSON_COLUMN_TYPES.has(type) or isMultiValueField(field)`.
`JSON_COLUMN_TYPES` is `STRUCTURED_JSON_TYPES` plus `MULTI_OPTION_TYPES`
plus the driver-internal `object` / `array` aliases. Memory's population
is the same predicate less those aliases and less a single-value media
field on an unmoved deployment (both recorded on `isJsonStoredField`).
On a schemaless direct call (an object never passed through
`syncSchema`), nothing is judged. Every operator answers per element as
before, as `SqlDriver.isJsonColumn` answers `false` for a table it was
never told about. Pinned. A field declared SCALAR (`text`) that holds an
array is not judged either.
- **H4.** `@objectstack/formula`'s `ORDERING_OPERATORS` docblock does
NOT declare a per-element reading for the query plane. It records a
non-alignment ("driver-memory's read, a frozen test driver, compares a
stored list element by element and keeps returning those rows ...
declared on objectstack-ai#15104"). objectstack-ai#15104 is the `$field` cross-field reference card,
shut as `not_planned` under the driver-memory investment freeze. It
rules nothing about the equality or ordering family on a stored list. So
this is a formula-plane record of observed behaviour, not a query-plane
contract, and no contract conflict stops the card. That docblock
sentence goes stale on declared fields once this lands (see Acceptance
notes).
- **H5.** objectstack-ai#21009 widens the same shared set to the text operators. Both
gates here read the set live, and the new suite iterates
`JSON_COLUMN_INCOMPATIBLE_OPERATORS` intersected with this driver's
vocabulary, with a floor of the nine `$`-spellings. So once both land,
memory refuses `$startsWith` / `$endsWith` / `$icontains` on these
fields with no edit here, and the suite pins them. Whichever of the two
lands second merges `main` and checks the other's members on its face.
The suite's `$contains` control is outside objectstack-ai#21009's scope.

## Pin sweep

- The ONE per-element pin the package carried on a declared field
flipped: `memory-20444-empty-operator.test.ts` had `{ tags: { $empty:
true, $ne: null } }` giving `r2`. It is now a refusal pin (`code` +
`status` + the shared message). The composition (`$empty` beside a
has-a-value sibling on one multi-value field) is kept through `$null:
false`, which answers `r2`. `driver-sql`/SQLite answers that row too,
and refuses the `$ne: null` spelling with the same body (measured on the
built driver).
- `memory-matcher-scalar-comparand-array-value.test.ts` pins per-element
answers on a column declared `text`. Those cells still hold, and a
header note now says the population there is a scalar-declared column.
- Repo-wide: only two tests outside this package bind the real driver
(`packages/runtime`'s two ruled consumers). Neither filters a
JSON-stored field. No other `INVALID_FILTER` pin moves.

## Tests (final head `13407b76f`)

- `pnpm --filter @objectstack/driver-memory exec vitest run
--maxWorkers=2`: **70 files, 1703 passed**. The first run after the
implementation, before any test edit: 69 files, 1 red of 1613, the
per-element pin flipped above.
- `pnpm --filter @objectstack/driver-memory run typecheck`: exit 0. `tsc
--listFiles` includes both edited test files.
- New `memory-21066-json-column-family-refusal.test.ts` (89 tests):
  - the card's five rows;
  - every family member on `owners` / `tags` / `meta` (`json`);
- ten shapes per field (bare, bare null, `$eq null`, `$ne null`, the
engine's `$ne` lowering, `$in []`, `$nin []`, under `$not`, an `$or`
branch after a holding one, `$eq` beside `$contains`);
- `count` / `findOne` / `updateMany` / `deleteMany` refusing with the
table untouched;
- the withheld message plus the logged `At filter.$or[1].owners.$gte:`
diagnostic;
  - comparand-first ordering;
  - eleven answered controls;
- the declaration boundary (undeclared object, scalar-declared column,
the gate with and without declarations);
- the analytics face, `query()` and `generateSql()`, including the
`cube.member` spelling, plus its log line and a `$contains` control.
- Ablations (`node scripts/ablation-replace.mjs`, wrap mode, run at
`de1fef341`; the two later merges touched no file in this package; each
restore proven blob == HEAD with `git diff HEAD` empty). The subjects
are this package's `src`, imported relatively, so no `dist` is involved:
- **A** `filterFieldDeclarations`' predicate forced to `() => false`:
**73 red / 37 green** of 110 across the new file and 20444. The 17 green
in the new file are exactly the answered controls, the
declaration-boundary trio, the premise, the comparand-first case and the
analytics `$contains` control.
- **B** the implicit-equality arm disabled: **7 red**, exactly the six
bare cases and the analytics bare case.
- **C** the operator arm disabled: **67 red**, every operator-based
refusal pin, the direct-gate test and the 20444 flip.
- Gate union, derived with `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` (no paths; 7 changed
paths, working tree clean) at `13407b76f`: **60 derived, 60 run, every
one exit 0**. Reconciled with `--ran`: "60 derived famil(ies) accounted
for — 60 run, 0 NOT-MEASURED (a DERIVED zero — all 60 recorded an exit
code and none of them is 3)". The same 60 also ran all-zero at the
previous merge head `5b75fe461`. At `de1fef341`,
`check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET, no dist
yet); it measured on both later heads.
- Driver conformance ledger (`node
scripts/check-driver-conformance.mjs`), before and after:
byte-identical. 50 covered cells, 0 DEBT, 0 exempt. The shared matrix
has no JSON-column or multi-value case-set, so this invariant is held by
the per-package pins, not the matrix.

## Acceptance notes

- **The class gains one method.**
`InMemoryDriver.filterFieldDeclarations` is tagged `@internal`. It is
not private only because the analytics face is another class.
`FilterFieldDeclarations` is not exported from the package root, but the
method does appear in the published `.d.ts`. objectstack-ai#20984 graded the analogous
public `filterContainsTest` as a surface widening (`Clause-②: yes
(widening)`). The seat graded it so (5929927010): the line is `yes
(narrowing)`, with the semver (`minor`) and the ADR-0087 marker
unchanged.
- **Surface beyond the claim's list.** `memory-driver.ts` and
`memory-analytics.ts` are edited. The gate cannot see a declaration on
its own, so the plumbing is the minimum the direction needs, and the
analytics face calls the same gate. No open PR touched either file when
read before the first edit.
- **The AST comparison-node door** (`{ type: 'comparison', field:
'owners', operator: '=', value: 'u1' }`) still answers per element on a
declared field: `d1`, `d3`, measured on the built driver. No seam emits
that form (the engine and the protocol hand a driver a FilterCondition),
so it is reachable only by a direct driver call. Left alone.
- **The shared sentence's mechanism clause** ("a field this driver
stores as a JSON TEXT column", "$in/$eq matched nothing") is
`driver-sql`'s, and is literally untrue of this driver and of the
engine's per-aggregation face. The prescription (`$contains`, an `$or`
of `$contains`) is right on all three. Inherited as objectstack-ai#21007 shipped it.
objectstack-ai#21009 is the PR that next edits the shared home.
- `@objectstack/formula`'s `ORDERING_OPERATORS` docblock
("driver-memory's read ... keeps returning those rows") is now true only
of undeclared objects. It is a comment, and no claim holds that file.
- **Tooling.** With the `turbo` 2.10.10 to 2.11.5 bump now on `main`,
every repo-scoped turbo run in an agent session appends a managed
"turborepo-agent-rules" block (an HTML-comment-delimited section) to
`AGENTS.md`. These include `pnpm exec turbo run build`, `pnpm
check:type-check-debt`, `check:query-options-erasure` and
`check:slot-lookup`. It happened repeatedly in this worktree and was
restored each time, and every gate derivation above was taken on a clean
tree; this PR does not touch `AGENTS.md`. Tracked as objectstack-ai#21146 (PR objectstack-ai#21151).

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

---------

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

size/xs skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tooling: turbo 2.11.5 writes a managed block into AGENTS.md in every agent worktree; opt out with "agentGuidance": false in turbo.json

2 participants