Skip to content

Add formula function library documentation - #87

Merged
xuyushun441-sys merged 3 commits into
mainfrom
copilot/add-formula-function-library
Jan 23, 2026
Merged

xuyushun441-sys merged 3 commits into
mainfrom
copilot/add-formula-function-library

Conversation

Copilot AI commented Jan 23, 2026 •

Copy link
Copy Markdown
Contributor

Formula fields existed with expression: z.string() but lacked function reference documentation, making them unusable without implementation knowledge.

Changes

Created /packages/spec/docs/formula-functions.md documenting 20 formula functions across 4 categories:

  • Text Functions (5): UPPER, LOWER, CONCATENATE, TEXT, LEN
  • Math Functions (5): SUM, AVERAGE, ROUND, CEILING, FLOOR
  • Date Functions (6): TODAY, NOW, YEAR, MONTH, DAY, ADDDAYS
  • Logical Functions (4): IF, AND, OR, NOT, ISBLANK

Each function includes:

  • Parameter types and return values
  • Multiple practical examples
  • Type compatibility matrix
  • Real-world use cases (price calculations, conditional logic, date arithmetic)

Usage

import { Field } from '@objectstack/spec/data';

const totalPrice = Field.formula({
  name: 'total_price',
  expression: 'ROUND(unit_price * quantity, 2)'
});

const discountedPrice = Field.formula({
  name: 'discounted_price',
  expression: 'IF(quantity >= 100, ROUND(price * 0.85, 2), price)'
});

Aligns with industry standards (Salesforce: 100+ functions, ServiceNow: 50+ functions).

Original prompt
  1. Formula Function Library Undocumented (field.zod.ts)

Current State:

Formula fields exist with expression: z.string() but no function reference
No documentation of available functions (SUM, AVG, TEXT, DATE, etc.)
Missing:

Function signature documentation
Data type compatibility matrix
Example formulas
Industry Comparison:

Salesforce: 100+ formula functions documented (TEXT, DATE, MATH, LOGICAL, ADVANCED)
ServiceNow: 50+ GlideSystem functions
Recommendation: Create packages/spec/docs/formula-functions.md with complete function library:

Formula Function Library

Text Functions

  • UPPER(text) - Converts to uppercase
  • LOWER(text) - Converts to lowercase
  • CONCATENATE(text1, text2, ...) - Joins text strings
  • TEXT(number) - Converts number to text
  • LEN(text) - Returns text length

Math Functions

  • SUM(field1, field2, ...) - Adds numbers
  • AVERAGE(field1, field2, ...) - Calculates average
  • ROUND(number, decimals) - Rounds to decimal places
  • CEILING(number) - Rounds up to integer
  • FLOOR(number) - Rounds down to integer

Date Functions

  • TODAY() - Current date
  • NOW() - Current date/time
  • YEAR(date) - Extract year
  • MONTH(date) - Extract month
  • DAY(date) - Extract day
  • ADDDAYS(date, days) - Add days to date

Logical Functions

  • IF(condition, true_value, false_value) - Conditional logic
  • AND(condition1, condition2, ...) - Logical AND
  • OR(condition1, condition2, ...) - Logical OR
  • NOT(condition) - Logical NOT
  • ISBLANK(field) - Check if field is null/empty

💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

@vercel

vercel Bot commented Jan 23, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
spec Ready Ready Preview, Comment Jan 23, 2026 10:59am

Request Review

Copilot AI and others added 2 commits January 23, 2026 10:55
Co-authored-by: xuyushun441-sys <255036401+xuyushun441-sys@users.noreply.github.com>
Co-authored-by: xuyushun441-sys <255036401+xuyushun441-sys@users.noreply.github.com>
Copilot AI changed the title [WIP] Add documentation for formula function library Add formula function library documentation Jan 23, 2026
Copilot AI requested a review from xuyushun441-sys January 23, 2026 10:58
@xuyushun441-sys
xuyushun441-sys requested review from Copilot and removed request for xuyushun441-sys January 23, 2026 11:05
@github-actions github-actions Bot added documentation Improvements or additions to documentation size/l labels Jan 23, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds comprehensive documentation for the formula function library, addressing the gap where formula fields existed (expression: z.string()) but lacked reference documentation about available functions.

Changes:

  • Created /packages/spec/docs/formula-functions.md with detailed documentation for 20 formula functions across 4 categories (Text, Math, Date, and Logical)
  • Added usage examples, parameter specifications, return types, and real-world use cases for each function
  • Included type compatibility matrix and best practices guidance

@xuyushun441-sys
xuyushun441-sys marked this pull request as ready for review January 23, 2026 11:05
@xuyushun441-sys
xuyushun441-sys merged commit 881c784 into main Jan 23, 2026
10 checks passed
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…ubmission moves to ctx.submitted (objectstack-ai#16344) (objectstack-ai#17195)

* wip(objectql): hide caller-forged readonly values from beforeUpdate (objectstack-ai#16344)

Checkpoint before the first heavy verify run, so the working tree is not the
only copy of the work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU

* fix(objectql)!: beforeUpdate receives the persist image; the caller submission moves to ctx.submitted (objectstack-ai#16344)

The update-side leak: a value sent for a `readonly: true` field was correctly
not persisted, and was still handed to `beforeUpdate`. A hook deriving columns
from the incoming record derived them from a value the row would never contain,
and those derived writes persisted — a row whose own audit trail cites values it
does not hold, with no error, no warning and a 200.

Ruled by the maintainer (decision batch objectstack-ai#87, 2026-09-08), option B, in two
halves that ship together:

  1. Caller-forged static `readonly` values are HIDDEN from the hooks' view of
     `ctx.input.data` and handed back at the post-hook confluence, so every
     engine-owned consumer below — `onFieldsDropped`, the readonly WARN,
     `strictReadonlyWrites`, the declared-field door, validation — reads the
     payload it read before and says the identical thing about it.
  2. The caller's submission as sent travels on `HookContext.submitted`
     (`packages/spec`), frozen, diagnostics only. plugin-auth's ADR-0092
     identity write guard is migrated onto it in this change, so its 403 and its
     security warn keep naming the non-whitelisted field.

The ENFORCEMENT pass does not move: it stays after the hooks, where it is the
only point that can tell a hook's stamp from a caller's forgery (objectstack-ai#5591 /
objectstack-ai#14088). `beforeInsert` is untouched (ruling C, objectstack-ai#14147) and `readonlyWhen` stays
hook-writable (objectstack-ai#9107).

The objectstack-ai#5591 docblock in engine-readonly-strip-caller-values.test.ts is superseded
in writing rather than deleted, and the case that pinned the old diagnostic
channel is re-pinned on the new one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU

* chore: regenerate the two artifacts the new HookContext member moves (objectstack-ai#16344)

Both produced by the repo's own generators, neither hand-edited:

  pnpm gen:system-context-census
      content/docs/permissions/system-context.mdx — six declared counts, 108 -> 109
      elevation read sites. The +1 is this change's own `opCtx.context?.isSystem`
      gate on the pre-hook hide pass. The census is green on symbols without a
      new row: the read lives in `ObjectQL.update`, already cited.

  pnpm --filter @objectstack/spec gen:schema && ... gen:docs
      content/docs/references/data/hook.mdx — one generated row for
      `HookContextSchema.submitted`.

`pnpm --filter @objectstack/spec check:generated` now reports all 15 generated
artifacts up to date. ⛔ content/docs/releases/ untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU

* test(objectql): re-pin the one suite the new hook input moves, and honour the caller's bound in the new double (objectstack-ai#16344)

Two reds reproduced locally at e8359df and fixed at their cause.

1. `engine-readonly-strip-signal.test.ts` — `[objectstack-ai#5591] a hook OVERWRITING a key
   the caller supplied` reached its subject through a guard,
   `if (ctx.input.data.work_duration !== undefined)`, which silently made the
   case depend on a SECOND fact: that the caller's forged read-only value is
   visible to the hook. It no longer is. The objectstack-ai#5591 verdict itself is unchanged
   and is re-pinned with an unconditional hook write; the fact the guard was
   quietly carrying gets its own case, asserting what this card ships — a hook
   that GATES on seeing the caller's forgery does not fire, and the column keeps
   its stored value. ⛔ Neither case is skipped, weakened or deleted.

2. `check:objectql-double-limit` — the `find` double in the new
   `engine-readonly-hook-input.test.ts` was limit-blind. It now applies the
   caller's bound after the filter, by presence, exactly as the gate prescribes.
   Local: exit 1 naming line 72 BLIND, then exit 0, "baseline key set verified
   against fd5cff2: no files added".

Local readings after both: objectql 4869/4869 in 289 files, plugin-auth
2245/2245 in 106, spec 13198/13198 in 470; all three typechecks OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU

* test(runtime): re-route objectstack-ai#14760's write-THROUGH control off the caller, and pin what objectstack-ai#16344 does to its old path (objectstack-ai#16344)

`Test Core (3/6)` was a THIRD failure, in a package I had not run:
@objectstack/runtime, src/sandbox/hook-input-writeback-readonly-provenance
.integration.test.ts. My local scope was the three packages I edited, so a
downstream consumer of the update path went unmeasured. Recorded as the miss it
was, not as a surprise.

objectstack-ai#14760's write-THROUGH control asks one thing: can leg 2 of the sandbox
write-back carry a mutation made THROUGH an object-valued readonly key, which
leg 1 (the `set` trap on `ctx.input`) structurally cannot see? It reached that
question by having the CALLER put the object on the payload. Since objectstack-ai#16344 a
caller cannot: the value is hidden from `beforeUpdate`, so
`ctx.input.locked_meta` is `undefined` and the body faults on the dereference —
measuring the hide, not the write-back.

The object now arrives the way the platform is still allowed to put it there: a
code hook's own write ahead of the body (objectstack-ai#5591/objectstack-ai#14088 semantics, which this card
did not move). The control is strictly SHARPER for it — the value under test is
unambiguously hook-authored, so a pass can no longer be explained by a caller
value leaking through — and leg 1 still cannot see the body's in-place mutation,
so leg 2 is still the only thing that can carry it. Asserting `who: 'hook'`
against a pre-hook `who: 'platform'` is what keeps it non-vacuous: a write-back
gone silent leaves the pre-hook value standing and fails here.

⛔ The old path is not deleted. It is pinned as its own case with the verdict
objectstack-ai#16344 gives it, and that verdict is the sharpest edge in this change: a body
that reaches through a caller-supplied readonly key now throws, and a body's
default `onError` is `abort`, so the caller's WHOLE write is refused where it
used to succeed. What it used to do was persist a value derived from the
caller's forgery, so refusing is the right direction — but the author sees a raw
`TypeError`, which names nothing actionable. Now recorded in the changeset's
"Who is affected" alongside the remedy: a body reads `ctx.previous`, since
`ctx.submitted` is deliberately not marshalled onto the sandbox face.

One mechanism worth the next reader's time, measured the hard way: the pre-hook
must be registered AFTER `bindHooksToEngine` and under a DIFFERENT packageId.
The binder is hot-reload friendly and opens by calling
`unregisterHooksByPackage(opts.packageId)`, so a pre-hook registered before it
under the same id is silently dropped — which presents exactly as the body
faulting on an absent key.

Local: runtime 9/9 on that file; shard 3/6 13/13 tasks; shards 2+4 83/83;
shard 5 72/72; shard 6 69/69.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU

* fix(objectql): set-to-undefined of an engine-hidden readonly key is a no-op, not a hook write (objectstack-ai#16344)

Contract-review finding F1. A `beforeUpdate` hook that assigns a hidden key
from the payload it was shown (`data.x = data.x`) reads `undefined` and
re-creates the key holding it. Three mechanisms then agreed the wrong way: the
recorder's `set` trap counted it as a hook write, the hand-back skipped the key
because `k in target`, and the static strip kept it on that record — so a driver
was handed `{ x: undefined }`.

On the memory driver that ERASES the stored read-only value; on a knex-backed
one `formatInput` does not drop `undefined` and `builder.update(payload)` hands
knex an undefined binding — a bare compile-time Error outside the ADR-0112
envelope. Neither is "the record the engine intends to persist".

At the confluence the key is now deleted, dropped from the sealed record, and
the ordinary hand-back puts the caller's value back for the strip to judge. The
write reads exactly as it would with no hook at all: stripped, reported on
`onFieldsDropped`, warned, refused under `strictReadonlyWrites`.

Dropping the key from `hookWrittenKeys` is load-bearing, not tidiness: handing
the caller's value back over a key the record still calls hook-owned would
credit the forgery with hook provenance. The narrowing reaches only keys this
card's pass hid, and only the one value no driver can store, so objectstack-ai#14088's
deliberate blindness to VALUE is unchanged for every key a hook can see.

The pin moves in both directions: the stored value now STANDS
(`completed_at === STAMPED`, the seed), and the FORGED negative stays.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU

* docs(objectql): carry the whitelist+readonly boundary and the persist-image rule where authors read them (objectstack-ai#16344)

Contract-review findings F2, F3, and F4/F5 riding along.

F2 — the changeset now carries the ADR-0092 boundary the round report claimed
was already in it. An UPDATE-whitelisted field that is ALSO declared `readonly`
now answers 403 where it answered 200-having-written-nothing, and the refusal
reads `(—)` because a whitelisted key is excluded from the guard's refused list
by design. No in-repo object is on that boundary; `patch` for plugin-auth
stands. The self-assignment row is also corrected to the verdict that now
holds: a no-op, stored value standing, not a persisted `undefined`.

F3 — `content/docs/protocol/objectql/security.mdx` is the hand-authored
authority for the update-side strip and said nothing about this card. It gains
a fifth rule ("hooks are shown the persist image, not the submission") and a
migration callout naming `ctx.submitted`, `ctx.previous`, the self-assignment
no-op and the sandbox `body` exclusion. Rule 2's trailing paragraph is
corrected while there: "cannot rescue one the caller supplied" has been false
since objectstack-ai#5591/objectstack-ai#14088 — a hook that ASSIGNS a caller-sent key owns the value and
the strip keeps that write. `content/docs/automation/hooks.mdx` takes the
one-line cross-reference from "Mutate the incoming record".

F4 — the `submitted` TSDoc said "FROZEN by the producer" without qualifying
depth. It is a shallow spread shallow-frozen, so a nested object reached
through a key here is the caller's own mutable reference. Not a laundering
route, but not a deep guarantee either, and now it says so.

F5 — the hidden set is the update strip's own subject set: author-declared
`readonly: true` AND runtime-owned types (`autonumber`, objectstack-ai#5503), not "statically
readonly" alone. Stated in the changeset and in the `input` contract note.

No generated artifact moves: the edited prose is TSDoc, and only the
`.describe()` string reaches `authorable-surface/data.json` and
`references/data/hook.mdx`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… key its five item-titled siblings already carry (objectstack-ai#18561)

Fixes objectstack-ai#16894

Clause-②: yes (widening)

`KanbanConfigSchema` now declares `titleField` as optional `z.string()`,
executing the director seat's decision batch objectstack-ai#87 (recorded at
objectstack-ai/objectui#8367 comment `5582071618`, confirmed by the
maintainer verbatim 「批 objectstack-ai#87 同意」). The direction was settled there; this
PR is the implementation only.

## What moved

| file | change |
|---|---|
| `packages/spec/src/ui/view.zod.ts` | `titleField:
z.string().optional()` on `KanbanConfigSchema`, plus the docblock that
records why it is optional |
| `packages/spec/src/ui/view.test.ts` | the card's four-leg probe (both
controls firing on the same call shape) and the optionality leg |
| `packages/spec/authorable-surface/ui.json` | regenerated — one added
row, `ui/KanbanConfig:titleField` |
| `content/docs/references/ui/view.mdx`,
`content/docs/references/api/protocol.mdx`,
`content/docs/references/data/object.mdx` | regenerated by `gen:docs` |
| `.changeset/16894-kanban-config-titlefield.md` | `@objectstack/spec:
minor` |

Regenerated, never hand-edited: `check:authorable-surface` wrote the
first and `pnpm --filter @objectstack/spec gen:docs` the rest.
`check:generated` reports all 15 artifacts up to date.

## Premise re-derivation — the card's sibling table does NOT match the
tree

Re-derived on this worktree at `origin/main` `79a046f8c` (the dispatch's
own derivation ran one commit behind, at `582d3e5`). `git grep -n
'titleField' -- packages/spec/src/ui/view.zod.ts` returns six carriers;
mapping each to the schema whose member list encloses it:

| line (pre-change) | owning schema | arity |
|---|---|---|
| `:1057` | `GalleryConfigSchema` (declared `:1050`) | optional |
| `:1071` | `TimelineConfigSchema` (declared `:1065`) | **required** |
| `:1407` | `CalendarConfigSchema` (declared `:1401`) | optional — the
ADR-0079 fallback docblock the ruling names |
| `:1490` | `GanttConfigSchema` (declared `:1484`) | required |
| `:1638` | `ListMapConfigSchema` (declared `:1631`) | optional |
| — | `KanbanConfigSchema` (declared `:1346`) | **absent — the defect**
|

Two corrections to the card body, neither of which disturbs the ruling:

- The card's table calls **Timeline optional**. It is **required** on
the tree.
- The card's table lists **five** schemas. There are **six** carriers:
`ListMapConfigSchema` also declares `titleField`, optional, and the card
does not mention it.

The ruling is unaffected: it prescribes the arity directly (`optional
z.string()`) and names `CalendarConfigSchema`'s docblock as the
reference, which is optional at `:1407`. So the shape landed here is the
ruled one, and the card's optional/required split was simply not
re-measured when it was written. The defect itself re-derives exactly as
filed: `KanbanConfigSchema` is a `strictObject` and refused `titleField`
by name.

## Evidence

| leg | command | verdict |
|---|---|---|
| build | `pnpm --filter @objectstack/spec build` | `VERDICT
command-exit 0` |
| ② typecheck | `pnpm --filter @objectstack/spec typecheck` |
`TYPECHECK_EXIT=0` — `check:test-typecheck: OK` |
| ② tests | `pnpm --filter @objectstack/spec test` | `TEST_EXIT=0` —
`Test Files 483 passed (483)` / `Tests 13775 passed (13775)` |
| targeted | `pnpm --filter @objectstack/spec exec vitest run
--maxWorkers=2 src/ui/view.test.ts` | `Test Files 1 passed (1)` / `Tests
376 passed (376)` |
| artifacts | `pnpm --filter @objectstack/spec check:generated` | 15 of
15 up to date |

① is empty by construction: `packages/spec` has no workspace
dependencies, so `--filter '@objectstack/spec^...' build` has an empty
closure. The package itself was built before any gate that reads
`dist/`.

③ Gate families were derived on this worktree, not inherited: `node
scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` over the real 7-path change set yields **108 families**. All
108 were run with each exit code landed to disk before being read, and
reconciled with `--ran`:

```
dispatch-gates --ran: 108 derived family(ies) accounted for — 105 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3)
```

**103 green.** The five non-zero results, none of them a finding against
this diff:

- `pnpm check:dual-build-cjs-loads`, `pnpm check:lean-entry-closure`,
`pnpm check:type-check-debt` — **exit 3, PREREQUISITE NOT MET**, all
three refusing because the whole monorepo is not built on this worktree.
NOT MEASURED, declared to CI, which checks out and builds fresh.
- `node scripts/check-plugin-teardown-shape.mjs --self-test` — **exit 1,
but a prerequisite refusal in its own words**: "cannot read the positive
control at `621a487607881c66b2899b7e3477115229a156b4` … Deepen the
clone". This container's checkout is shallow. NOT MEASURED. The gate
itself (without `--self-test`) ran **green**.
- `pnpm check:cross-package-test-inputs` — **exit 1, pre-existing on the
base tree**, proven by control rather than asserted. With all seven
changed paths restored to `79a046f8c` (and the changeset removed), the
gate fails identically; the finding it prints names
`packages/cli/test/init-created-files-summary.e2e.test.ts` descending
`packages/spec/dist/`, and this diff touches neither that test, nor
`turbo.json`, nor any declaration table. Restore was proven by `git diff
HEAD` empty plus a `git hash-object` match against every HEAD blob.

**Lint, as a proven narrowing rather than a repo-wide sweep.** `pnpm
exec eslint --no-inline-config --format json` over the two changed
TypeScript files: **exit 0, 2 files, 0 errors, 0 warnings**, the file
count read from the JSON output's own length. The population it narrows
from is **6798** files — computed from ESLint's own resolved config via
`ESLint#isPathIgnored` over `git ls-files`, not estimated. The narrowing
excludes nothing, and that is a property of this repository's config
rather than a hope: `eslint.config.mjs` states it in its own comment —
this repo "runs one `eslint.config.mjs`, which never enables type-aware
linting (no `parserOptions.project`, no typed `@typescript-eslint`
rules) for ANY file, test or not." With no rule reading types across a
file boundary, a diff confined to these two files cannot move the
verdict on any of the other 6796. The repo-wide `pnpm lint` run remains
CI's.

Every reading above was taken at `6d01b4b`, this branch's final commit,
on a tree whose `git status --porcelain` is empty. `origin/main` had not
moved from the branch point (`79a046f8c`) when the PR was opened, so no
merge was owed.

## Acceptance notes

- **`packages/lint` does not field-check the key this PR declares.**
`POSITIONS.kanban` in
`packages/lint/src/validate-list-view-field-refs.ts` lists
`groupByField` (error), `summarizeField` (warning) and `columns`
(warning) — and no `titleField`. Every sibling face lists it:
`calendar.titleField` warns, `gantt.titleField` and
`timeline.titleField` error. So from this release a stale or misspelled
`kanban.titleField` names nothing and is reported by nothing, while the
identical typo one block away is caught. Read-only on this card by
dispatch, so it is reported and not edited; nothing in this PR turns it
red, and no gate reconciles that table against the spec's member lists.
Worth its own card.
- **The node-level half (objectstack-ai/objectui#7742) has no home here,
and the card's conditional resolves to "no".** `ObjectKanbanPropsSchema`
in `packages/spec/src/ui/component.zod.ts` **already declares**
`titleField: z.string().optional()`, described as "Legacy fallback for
`cardTitle` (the board reads `cardTitle || titleField`). Prefer
`cardTitle`". That is a different semantic on a different schema in a
different file — a deprecated alias for `cardTitle`, not a view-config
field binding — so one declaration cannot serve both faces and this card
is not widened. That file is also held by objectstack-ai#18305 / PR objectstack-ai#18403; it was
read, never touched.
- Noted, not filed: `KanbanConfigSchema` carries no object-level
`.describe()`, where `GalleryConfigSchema`, `TimelineConfigSchema`,
`CalendarConfigSchema` and `ListMapConfigSchema` all do, so its
nested-shape heading in the generated reference renders with no
description sentence. Cosmetic, generated-docs only, no accept set
involved — left for whichever card next edits this schema.
- The issue body was checked for sanitizer truncation: none found. It is
6732 characters through the REST read and terminates on its own `Refs:`
line.

## File surface

Two files beyond the dispatch's declared surface, both **generated by
the mandated `gen:docs` run** and neither hand-edited:
`content/docs/references/api/protocol.mdx` and
`content/docs/references/data/object.mdx`. The kanban config shape is
inlined on those two reference pages as well as on `ui/view.mdx`, so
each picks up `titleField?: string` in its `kanban` row. Omitting them
would leave `check:docs` red. Every hunk in all three files is this one
key and nothing else.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…n names no sys_position row (objectstack-ai#16712) (objectstack-ai#20292)

Fixes objectstack-ai#16712
Clause-②: yes
Fixes objectstack-ai#20297

Executes the maintainer-confirmed ruling on objectstack-ai#16712 (batch objectstack-ai#87, option
A): a `sys_user_position` write whose `position` names **no
`sys_position` row at all** is refused, instead of answering 201 over an
assignment that resolves to nothing. Dispatch: PM session
`session_01TEah6PeJGjxJfbHaySJjLQ`, claim comment 5858098043, branch
`claude/issue-16712-position-catalog-refusal`.

## What changes

A new engine middleware,
`packages/plugins/plugin-security/src/position-catalog-refusal.ts`,
registered by `SecurityPlugin.start()` right AFTER its security
middleware (so it runs INSIDE it, after the delegated-admin gate and the
CRUD check, the same placement the ADR-0094 permission-set data door
uses).

- **Predicate:** exactly "no catalog row carries this name". A
deactivated position (`active: false`) is still a catalog row, so an
assignment naming it is accepted and, as before, grants nothing
(ADR-0049 shape E). The catalog is the WRITER's: it is read under `{
...context, isSystem: true }` (the engine lookup probe's spelling), so
the reach is the writer's organization plus organization-less positions.
A name only another organization carries is refused exactly like a name
no organization carries (objectstack-ai#20297; see the patch-round section below).
- **Envelope:** `400 VALIDATION_FAILED`, one `fields[]` entry per
offending value: `field: 'position'`, `code: 'reference_not_found'`,
`constraint: { target: 'sys_position', targetField: 'name' }`. This is
the envelope the row's sibling lookup columns (`user_id`,
`organization_id`, ...) already answer with. **No new error code**:
`VALIDATION_FAILED` is a registered ADR-0112 code (built with
`validationFailure` from `@objectstack/types`, which both HTTP doors map
to 400), and `reference_not_found` is a member of the closed ADR-0114
field catalog. Nothing in `packages/spec` changes.
- **Message:** names the value, says the column takes the catalog NAME,
and names the fix. When the value is the record id of a position the
writer's own organization can see (the hotclm id-spelling trap), it
names that position and says to write its name.
- **Writes judged:** every non-system insert (one row or a batch; the
batch is refused whole), a non-system update by id that CHANGES
`position`, and a predicate update (`multi: true`) that sets it.
- A `position` that is not a string is judged too, by its string form: a
number, bigint or boolean by `String(value)`, an object or array by its
JSON text. The engine's `text` validation stores every one of these with
201, except an operator object (next bullet).
- Left to the engine, and only these: `null` and a blank string
(`required`), a value whose `String()` form is longer than the column
(`max_length`), and an operator object. An operator object is a plain
object with a declared filter operator as an own key, such as `{ $in:
[...] }`; the engine refuses it as `invalid_type` (objectstack-ai#5922).
- An update that edits another column, or echoes the stored `position`
back unchanged, is not judged, so an existing row whose name has left
the catalog stays editable. The comparison reads string forms, so `123`
echoed over a stored `'123'` counts as unchanged.
- **Writes NOT judged, deliberately:** every `isSystem` write, the same
stand-down the engine's own lookup check (`assertReferencesResolve`)
takes. Measured reason for the seed door: on a fresh single-posture boot
`AppPlugin.start()` writes `stack.data` inline
(`packages/runtime/src/app-plugin.ts`, the `seedLoader.load` branch),
while the declared position catalog is seeded on `kernel:ready`
(`bootstrapDeclaredPositions`, `security-plugin.ts`), so a refusal at
the seed door would fail every authored assignment seed on the first
boot and pass it on the second. Also not judged: invitation acceptance
(`invitation-placement.ts` `apply`, system context) and the platform
bootstraps. This was a code reading, not a live boot of a seed-carrying
stack.
- **Fails open** (with a `warn`) when the catalog cannot be read, the
engine lookup probe's stance.

Two consequences outside `plugin-security/src`, both required by derived
gates:

- `content/docs/permissions/system-context.mdx`: new row 23b for the
refusal's `isSystem` stand-down, and the census counts regenerated with
`node scripts/check-system-context-census.mjs --fix` (105 to 106 sites).
`check:system-context-census` requires an anchor for every elevation
read site.
- `.changeset/16712-position-catalog-refusal.md`:
`@objectstack/plugin-security` `minor`, a `**BREAKING**` banner, and the
ADR-0087 disposition `not-required (no-migration-prescription)`.
`check:adr-0087-registration` reads it as `[BREAKING+bang] not-required
(no-migration-prescription)`. The level follows the WHICH LEVEL rule
(decision batch objectstack-ai#35): `Clause-②: yes` takes at least `minor`, and
breaking-ness is carried by the banner, not the level.

## The two pre-enumerations (the ruling's gate), in-repo half, tree
`4d7e740d3b`

The out-of-repo halves were measured EMPTY on the card (hotcrm
`2f7b2326`, hotclm `14c899fc`, triage comment 5857658468).

**Precondition 2 (a catalog-less or membership-derived name written into
`sys_user_position.position`): EMPTY.** Every non-test writer:

| writer | context | values written | catalog-less? |
|:--|:--|:--|:--|
| `packages/plugins/plugin-security/src/invitation-placement.ts` `apply`
| `isSystem`, on invitation acceptance | `intent.positions`, the names
the issuer put on the invitation (gate-checked at issuance). No literal.
| no |
| `examples/app-showcase/src/security/seed-approval-demo.ts`
`assignPositions` | `isSystem`, on `kernel:bootstrapped` (after the
catalog bootstrap) | `manager`, `finance`, `legal`, `exec`, `auditor`.
All five are declared in
`examples/app-showcase/src/security/positions.ts` `allPositions`. | no |
| `packages/verify/src/rls.ts` `provisionRlsPositionPersona` |
`isSystem`, post-boot | `declaredPositionNames(config)`, the stack's own
declared names | no |

- Search: the write verbs (`insert`/`create`/`upsert`/`update`/`*Many`)
naming `sys_user_position` returned 11 hits, all in `*.test.ts`. The
non-test write calls in every file naming the object add the three rows
above.
- Positive control: the same verb pattern finds the
`sys_user_permission_set` writer in non-test code.
- `org_member`, `everyone` and the other membership-derived or anchor
names are resolver PROJECTIONS (`resolve-authz-context.ts`, the
`mapMembershipRole` loop and the implicit `everyone` push), never stored
rows. A `position:` value spelling any of the eight anchor or built-in
names has 0 non-test hits, and 12 in tests (control).

**Precondition 1 (in-repo half), re-confirmed on today's tree: EMPTY.**
No shipped `stack.data` seed carries `sys_user_position`. The only
`object: 'sys_user_position'` in non-test code is the gate dry-run
literal in `invitation-placement.ts`. Seed files for other objects are
found by the same search (control).

## Evidence (first round, head `cbdd0e70`; the patch-round section below
is the evidence for the current head)

**Live HTTP, real composition** (fresh showcase, `pnpm dev -- --fresh
-p` on a random port, account created through `POST
/api/v1/auth/admin/create-user`; the server was stopped by its recorded
process group):

| request | answer |
|:--|:--|
| `POST /data/sys_user_position` with `position` = the `auditor` row's
id | **400** `VALIDATION_FAILED`, `fields[0]` = `position` /
`reference_not_found`, message says to write `auditor` |
| same, `position: 'totally_not_a_position_zzz'` | **400**
`VALIDATION_FAILED` / `reference_not_found`, generic remedy |
| same, `position: 'auditor'` | **201** |
| that holder reads `showcase_inquiry` | **200, 3 rows** (the name
resolves) |
| `PATCH` that row, `position` = the auditor id | **400** |
| `PATCH` that row, `reason` edited, `position` echoed unchanged |
**200** |
| `POST /data/sys_user_position/createMany`, `[finance, fin4nce_typo]` |
**400**, names only `fin4nce_typo`; nothing stored |

**Unit and integration** (`position-catalog-refusal.test.ts`: 14 cases
at `cbdd0e70`; each patch-round section below gives the count for its
head): a real `ObjectQL` over SQLite, with the real `SecurityPlugin`
registered the way a kernel composition registers it. Every refusal pin
asserts `code` and `status` through `resolveThrownHttpError`, never a
bare throw.

- **The ruling's control leg:** an id-spelled assignment is refused, and
the same account reads 0 rows. A direct `sys_user_permission_set` grant
of the same set to the SAME account then reads 3 rows.
- **Both accepted halves:** a catalog name resolves (the holder reads 3
rows). A deactivated position's name is accepted and stored, and grants
nothing.
- **Authorization first:** a plain member, and an
`organization_admin`-shaped caller (wildcard `modifyAllRecords` plus an
explicit per-table deny), get `403 PERMISSION_DENIED` for a bogus name
and for a real name alike.
- **Walled posture, two organizations:**
  - a name no organization carries is refused;
- a name only another organization carries: pinned ACCEPTED at
`cbdd0e70`, under the literal predicate. Since objectstack-ai#20297 it is REFUSED, and
that pin is reversed (patch round below);
  - the id hint never names another organization's position;
- **the org boundary for a VALID foreign organization** (the leg the
card carried as NOT MEASURED): an `org_a` writer stamping
`organization_id: 'org_b'` gets `403` at the tenant wall, identically
for all three names. Measured at the engine layer with the `isolated`
posture, not over HTTP.

**Ablations** (each through `scripts/ablation-replace.mjs` in WRAP mode,
plus a shell `trap` restoring the absolute path from `HEAD`; every
restore proven by blob hash == `HEAD` and an empty `git diff HEAD`). The
subject resolves through relative imports to `src/`, so no rebuild or
`dist/` preflight applies:

| ablation | mutation | result |
|:--|:--|:--|
| A1 | the refusal never fires | **7 red** (every refusal pin: "expected
the write to be refused, but it succeeded"), 7 green |
| A2 | the catalog judged at the TOP of the security middleware, before
authorization | **2 red**: authz-first (`expected 'VALIDATION_FAILED' to
be 'PERMISSION_DENIED'`) and the foreign-org wall. The first attempt was
a no-op: the tool refused it because the replacement re-contained the
anchor. It is reported, not counted. |
| A3 | an unchanged `position` judged too | **1 red** (the fossil-edit
pin) |
| A5 | the id hint drops its organization filter (at `cbdd0e70`; the
patch round removes that filter, and the hint read is scoped instead,
see C1 below) | **1 red** (the hint named `qa_b_only`) |

**Local verification at head `cbdd0e70`:**

- `pnpm --filter @objectstack/plugin-security exec vitest run
--maxWorkers=2`: **141 files / 2910 tests passed**.
- `pnpm --filter @objectstack/plugin-security typecheck`: exit 0. The
test layer compiles, with 0 debt.
- `node scripts/pm/dispatch-gates.mjs --commands` over the actual diff:
**94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN**, all exit 0 (`--ran`
reconciliation green). This includes `check:dual-build-cjs-loads`,
`check:i18n` and `check:type-check-debt` after a full workspace build.
- Lint, narrowed and proven:
- (1) The population read from eslint's own config (`--print-config`):
the three changed `.ts` files are in it, and the `.mdx` and `.md` files
are not.
- (2) `eslint --no-inline-config --format json` counted 3 files, 0
errors and 0 warnings.
- (3) No `parserOptions.project` is set anywhere (type-aware linting is
not enabled), so this diff cannot move a verdict on an untouched file.
- Declared to CI, not run locally: the `packages/qa/dogfood` suites. The
three that write `sys_user_position` over HTTP (`delegation-of-duty`,
`showcase-permission-zoo`, `delegated-admin-invite`) all write catalog
names.

## The predicate's tenancy scope: decided on objectstack-ai#20297 (B)

The first round applied the ruled predicate literally and read the
catalog unscoped. On a walled posture that accepted a name only another
organization carries, and it raised the scope as an open question.
- Triage routed that question as precedent-decided (objectstack-ai#20297, comment
5859496956): the read is the writer's organization plus
organization-less rows, in the engine lookup probe's `{ ...context,
isSystem: true }` (objectstack-ai#19808, objectstack-ai#19819, objectstack-ai#19860).
- The patch round below executes it. The objectstack-ai#16712 ruling itself is
unchanged: a deactivated position is still a catalog row.

## Acceptance notes

- **`security/explain` naming an unresolvable entry** (hotclm evidence,
5611199715): out of scope by dispatch. Noted, not filed.
- **Lint as the authoring-time second carrier**:
`packages/lint/src/validate-security-posture.ts` lints
`sys_user_position` seed rows but does not check that `position` names a
declared position. It is the natural carrier for the seed door this
refusal stands down on. Out of scope by dispatch; noted, not filed.
- **Invitation issuance is not judged by this refusal**:
`assertIssuable` dry-runs only the delegated-admin gate, and acceptance
writes under a system context. An invitation naming a catalog-less
position is still accepted at issuance, and the placement lands silently
at acceptance. This is a door this change does not cover. Carrier:
whoever next touches `invitation-placement.ts`; no carrier is named
today.
- The refusal message is English only. Localizing it needs a
message-catalog key in `packages/spec`, another lane's.
- The write preview (`ObjectQL.validate`) runs no middleware, so it does
not report this refusal, the same as it does not report the engine's
lookup check today.
- The claim's surface listed `plugin-security/src` plus the changeset.
The dispatch's file-surface clause names
`packages/plugins/plugin-security/src/` as the landing, so the
middleware registration in `security-plugin.ts` (one
`registerMiddleware` call, with its import and comment) sits inside it,
away from the `writeCheckPolicies` docblock. The `system-context.mdx`
row is the one addition outside it, owed to
`check:system-context-census`.

<sub>Seat's append (domain:services objectstack-ai#6021,
`session_01TEah6PeJGjxJfbHaySJjLQ`): the objectstack-ai#20297 dev's patch-round
section, verbatim from its report 5860074760. The seat also replaced the
`Held-for:` line in the head with the closing line for that card.</sub>

## Patch round (objectstack-ai#20297)

Executes the triage routing on objectstack-ai#20297 (comment 5859496956), option
**B**: the predicate the objectstack-ai#16712 ruling fixed now reads the WRITER's
catalog, meaning the writer's organization plus organization-less rows,
in the engine lookup probe's spelling `{ ...context, isSystem: true }`
(`assertReferencesResolve`, objectstack-ai#19808). The ruling is not re-opened: a
deactivated position is still a catalog row, so shape E stays accepted.
Dispatch: PM session `session_01TEah6PeJGjxJfbHaySJjLQ`, claim
5859557977.

**What changed** (head `cb1d5be9`)

- `namesWithoutCatalogRow(deps, names, context)` and
`idSpellingHints(deps, values, context)` now take the writer's context
as a required parameter. They read `sys_position` under
`catalogReadContext(context)`, which is `{ ...context, isSystem: true
}`, and `assertPositionNamesCatalogRow` passes `opCtx.context`.
`security-plugin.ts` is unchanged in this round.
- The id hint's organization post-filter is removed. The hint now reads
exactly what the check reads (see the C1 ablation below).
- The by-id pre-image read stays a bare `{ isSystem: true }`. It is
measured unreachable for a foreign row id, and that is pinned (below).
- Module docblock: a new "Whose catalog" section states B and cites
objectstack-ai#20297 with the engine precedent (objectstack-ai#19808, objectstack-ai#19819, objectstack-ai#19860). "Where it
runs" no longer says authorization-first is what protects an unscoped
read. Authorization-first itself stays (P1 below).
- Changeset: the "read across every organization" line is gone. A "Whose
catalog" paragraph says the catalog is the writer's organization's
positions plus the organization-less ones, and that a name only another
organization carries is refused like any unknown name. `minor`,
`**BREAKING**`, `!`, `Clause-②: yes` and the ADR-0087 disposition are
kept; `check:adr-0087-registration` reads `[BREAKING+bang] not-required
(no-migration-prescription)`.
- `system-context.mdx` row 23b now describes the scoped read.
- `origin/main` `7b1e4a48` is merged in (merge commit `2f036209`). The
only conflict was the census file-count row. `pnpm
gen:system-context-census` then re-derived symbols 88 → 89 (`75804ad6`).
The PR now reads `mergeable_state: clean`.

**Mechanism readings.** These use the walled two-organization fixture: a
real `ObjectQL` over SQLite, with the real `SecurityPlugin`. The
"before" column was measured at `75804ad6`, whose refusal module is
byte-identical to `cbdd0e70`'s; the "after" column is the pins at
`cb1d5be9`.

| write, from an `org_a` admin | before | after |
| --- | --- | --- |
| insert `position: 'qa_b_only'` (only `org_b` carries it) | 201 | 400
`VALIDATION_FAILED` / `reference_not_found`, message identical to the
exists-nowhere one |
| insert `position: 'nope_position'` (no organization carries it) | 400
| 400 |
| update by id of an `org_b` row, unknown name | 403 `PERMISSION_DENIED`
| 403 |
| update by id of an id that exists nowhere, unknown name | 403
`PERMISSION_DENIED`, the same message | 403 |

- **The pre-image read is never reached for a foreign id.** The security
middleware answers a foreign id and a nonexistent id identically, before
the refusal runs, so the pre-image read is left bare.
- **Scoped vs bare lookup by name.** Looking up `sys_position` by name
under `{ ...orgAdminContext, isSystem: true }` finds `qa_b_only` 0 times
and `qa_a_own` once. Under a bare context, or a context with no tenant,
each is found once.
- **A writer with no organization never reaches the refusal on the
isolated posture.** The security middleware answers `403` ("no active
organization") first, for an insert and for a predicate update. So the
"reads every organization" behaviour is pinned directly on
`namesWithoutCatalogRow`.
- **`single` posture.** The fixture seeds its catalog the way a `single`
deployment seeds its declared catalog, with no organization, so both
rows read a null `organization_id`. That is not true of every row on a
`single` deployment: a position created through the data door by a
session with an active organization is stamped with it. On a deployment
holding one organization, the scoped read (`organization_id = tenant OR
organization_id IS NULL`) still covers the whole catalog. A `single`
deployment holding more than one organization still boots;
`TenancyService` reports that state at `error` (objectstack-ai#17010). In that state
each writer reads its active organization's positions plus the
organization-less ones. The existing single-posture tests carry no
tenant, so they never exercise the organization-less term. A new pin
writes with `tenantId: 'org_a'`: `qa_auditor` is accepted and an unknown
name is refused.

**Tests** (`position-catalog-refusal.test.ts`, 14 → 18 cases)

- **Reversed, not deleted.** A name only another organization carries is
now refused `400 VALIDATION_FAILED`, `reference_not_found` at
`position`. Its message is byte-identical to
`positionNotInCatalogMessage('qa_b_only')`, and its envelope matches the
exists-nowhere case key for key. A predicate update that sets that name
is refused the same way.
- **New.** The writer's own organization's name is accepted (the
control). The scoped and unscoped readings are pinned on the function. A
foreign row id is pinned to answer like a nonexistent id. An
organization-bound writer on the single posture is pinned.
- **Unchanged and green.** The control leg, shape E, the batch / multi /
echo legs, the `isSystem` stand-down, 403-first, the
foreign-organization wall and the id hint.

**Ablations** (at `cb1d5be9`). Each ran through
`scripts/ablation-replace.mjs` in WRAP mode plus a shell trap, and every
restore was proven by blob == `HEAD` and an empty `git diff HEAD`. The
subject resolves through relative `src/` imports, so no rebuild or
`dist/` preflight applies.

| ablation | mutation | result |
| --- | --- | --- |
| B1 | the catalog name read reverted to the bare `SYSTEM_CTX` | 2 red:
the reversed pin ("expected the write to be refused, but it succeeded")
and the organization-bound leg of the function pin |
| C1 | the id-hint read reverted to the bare `SYSTEM_CTX` (post-filter
already removed) | 1 red: the hint names `qa_b_only` |
| P1 | the catalog judged at the top of the security middleware, before
authorization | 3 red: 403-first, the foreign-row-id pin
(`nope_position` answers 400 instead of 403), and the
foreign-organization wall |

C1 doubles as the post-filter measurement. With the scoped read and no
post-filter, the full suite is green, and the hint pin fails only when
the read itself is unscoped. So the post-filter is not kept.

**Local verification at `cb1d5be9`**

- `pnpm --filter @objectstack/plugin-security exec vitest run
--maxWorkers=2`: 141 files / 2914 tests passed.
- `pnpm --filter @objectstack/plugin-security typecheck`: exit 0. The
refusal test is in the `tsconfig.test.json` program (checked with
`--listFiles`).
- `node scripts/pm/dispatch-gates.mjs --commands` over the actual diff:
  - 94 derived, 94 run, all exit 0;
  - `--ran` reconciliation: "94 run, 0 NOT-MEASURED (a DERIVED zero)";
- this includes `check:query-options-erasure`, back to 236 sites: the
pre-image pin's read had first been cast to `any`, and `cb1d5be9` types
it.
- Also run: the four artifact-roster gates flagged for this diff, and
the diff-scoped `check-issue-citations.mjs` (6 citations, 6 resolve).
- Lint, narrowed and proven:
- `eslint --print-config` puts the three `.ts` files in the population
and the `.md` / `.mdx` files outside it;
- `eslint --no-inline-config --format json` counted 3 files, 0 errors
and 0 warnings;
- `eslint.config.mjs` sets no `parserOptions.project`, so this diff
cannot change a verdict on an untouched file.

**Acceptance notes added this round**

- **A context carrying only `organizationId`** (no `tenantId`) is not
tenant-scoped by the engine's driver options. It reads every
organization here (measured on the fixture) and, by the same
driver-options reading, in the engine's own lookup probe (code reading).
It cannot reach this refusal on the isolated posture, where it gets 403
first. Noted, not filed.
- **Under the `group` posture** the scoped read follows the engine's
membership union, so a name from another organization the writer belongs
to is accepted. This is a code reading, not measured. Noted, not filed.

<sub>Seat's append: the round-4 section, from the objectstack-ai#20297 dev's report
5860889632, with "stored text" corrected to "string form" where the
SQLite measurement shows the two differ (`123` is stored as
`'123.0'`).</sub>

## Patch round 4 (contract re-review 5860712690)

**Measured first.** On head `ebf00fd8`, before any code change, I ran
non-system inserts and by-id updates as an admin, on both the single and
the walled fixture, with the same result on each:

| `position` written | answer | stored as |
| --- | --- | --- |
| `123` | 201 | `'123.0'` |
| `true` | 201 | `'1.0'` |
| `{}` | 201 | `'{}'` |
| `['x']` | 201 | `'["x"]'` |
| `[]` | 201 | `'[]'` |
| `NaN` | 201 | `NULL` |
| `10n` | 201 | `'10'` |
| by-id update to `123` or `true` | 200 | `'123.0'` / `'1.0'` |
| `''`, `' '` or `null` | 400 `VALIDATION_FAILED`, `required` | nothing
|
| a 101-character string | 400 `VALIDATION_FAILED`, `max_length` |
nothing |

So the reviewer's premise held: the engine refuses only `null`, blank
strings and over-long values, and stores everything else. Branch (a)
applies.

**What changed** (head `6e1ef6aa`)

- `judgedName(value)` in `position-catalog-refusal.ts` now judges every
value except those the engine answers itself.
  - A number, bigint or boolean is judged by `String(value)`.
- An object or array is judged by its JSON text (in the measurement,
SQLite stored `{}` and `['x']` in exactly that form). It is never judged
by `String(['x'])`, which reads `'x'` and would accept an array naming a
real position that then resolves nothing.
- The engine answers `null`, a blank string, and any value whose
`String()` form is longer than the column (its `max_length` check reads
`String(value)`). Patch round 6 below adds the one refusal this missed:
an operator object, `invalid_type` (objectstack-ai#5922).
- The refusal is the usual one: `400 VALIDATION_FAILED`,
`reference_not_found` at `position`, with the value's string form as
`value` and in the message.
- The by-id unchanged-value check compares string forms.
- The docblock's stand-down sentence now names only the refusals the
engine really makes.
- The changeset and `system-context.mdx` are unchanged; their "Which
writes" and "Such a write is now refused" text is true as written.

**Tests** (21 cases, up from 18). Every refusal pin asserts `code` and
`status`.

- **Insert:** `123`, `true`, `{}` and `['qa_auditor']` are each refused,
with `value` equal to `'123'`, `'true'`, `'{}'` and `'["qa_auditor"]'`,
and nothing is stored.
- **By-id update:** `123` and `true` are refused, and the stored row is
untouched.
- **Echo:** `123` over a stored `'123'` is not judged.
- **Id hint:** now also asserts `VALIDATION_FAILED` / 400, plus `field`
and `code` on `fields[0]`, for both refusals.

**Ablations** (at `6e1ef6aa`, through `scripts/ablation-replace.mjs` in
WRAP mode plus a shell trap; every restore was proven by blob == `HEAD`
and an empty `git diff HEAD`):

| ablation | mutation | result |
| --- | --- | --- |
| N1 | only strings are judged | 2 red: the insert and update pins
("expected the write to be refused, but it succeeded") |
| N2 | the echo compares raw values | 1 red: the echo pin (`123` is
refused as `'123'`) |
| N3 | no JSON form, so objects fall back to `String()` | 1 red: the
insert pin (`{}` is judged as `'[object Object]'`) |

**Verification at `6e1ef6aa`**

- plugin-security: 141 files / 2917 tests passed; typecheck exit 0.
- `dispatch-gates --commands`: 94 derived, 94 run, all exit 0. The
`--ran` reconciliation reports a derived zero.
- `check:query-options-erasure` holds at 236, since no new `find` was
added.
- `check:adr-0087-registration` reads `[BREAKING+bang] not-required
(no-migration-prescription)`.
- The diff-scoped issue-citation check: 7 citations, all resolve.

**Acceptance note.** On SQLite a non-string is stored as the driver's
text (`'123.0'`, `'1.0'`), not as `String(value)`. So a non-string whose
`String()` form happens to equal a catalog name, for example `true`
where a position is named `true`, is accepted and stored in a form that
resolves nothing. `sys_position.name` has no pattern that rules such
names out. The root cause is the engine's `text` leniency for non-string
input, which is outside this card; it is measured at the engine layer
only.

<sub>Seat's append: the round-6 section, verbatim from the objectstack-ai#20297 dev's
report.</sub>

## Patch round 6 (contract review 3, 5861153477)

**Measured first** on head `0e5f4c79`, on the single fixture, with the
refusal in place and then with it ablated (`ablation-replace` WRAP; the
restore was proven by blob == `HEAD`):

| `position` | with the refusal | engine alone |
| --- | --- | --- |
| `{ $in: ['x'] }`, `{ $in: [], a: 1 }`, `{ $regex: 'x' }`, `{ $or: []
}` | 400 `invalid_type` | 400 `invalid_type` |
| by-id update to `{ $in: ['x'] }` | 400 `invalid_type`, row untouched |
400 `invalid_type` |
| `{ a: 1 }`, `{ $foo: 1 }` | **201, stored** | 201 |
| `'{nope_tok}'` | **201, stored** | 201 |
| `'{current_user_id}'`, `'{today}'` | 400 `reference_not_found` | 201 |
| `{}`, `[{ $in: 1 }]` | 400 `reference_not_found` | 201 |

The reviewer's predicted outcome (`reference_not_found` pre-empting the
engine) did not occur, but its cause is real. `invalid_type` came
through only because the refusal failed open.

- **The mechanism.** The catalog read put the value's text in `where`. A
fully-wrapped `{…}` comparand is a filter placeholder
(`classifyFilterToken` in `@objectstack/spec`, applied by
`@objectstack/core`'s `resolveFilterTokens`).
- An unknown one throws `FILTER_TOKEN_UNKNOWN`. The refusal then logged
"catalog could not be read" and let the write through.
- A known one is replaced by its value, so the lookup judged a different
name.
- **The consequence.** Every object without a declared operator but with
at least one key, and every brace-wrapped string, bypassed the refusal.

**What changed** (head `c282e608`, `position-catalog-refusal.ts` only):

- **Operator objects are left to the engine.** `judgedName` stands down
on them using `isFilterOperatorObject`, a narrow mirror of the engine's
module-private `filterOperatorKeysIn` built from the same spec inputs
(`isPlainRecord`, no `Date`, an own key in `ALL_OPERATORS` or the
retired operators). It is not a `$`-prefix test: `{ $foo: 1 }` is
judged.
- **Placeholder-shaped names are looked up literally.** `catalogCarries`
handles every name `classifyFilterToken` would treat as a placeholder:
it reads the catalog names that share its first character
(`$startsWith`) and compares in code, under the same scoped context.
Such a name is therefore judged, never resolved and never failed open.
- **The id hint** skips placeholder-shaped values.
- **Docblock.** The stand-down bullet now names the engine's three
refusals exactly: `required`, `max_length`, and `invalid_type` for an
operator object. The `stringForm` doc now says a bigint is
`String(value)` and only null, undefined, a symbol, a function or an
unserialisable object gives `undefined`. "Fails open" says a
placeholder-shaped name never falls open.
- **The changeset is unchanged**; its "Which writes" is still true as
written.

**Tests** (24 cases, up from 21). Every refusal pin asserts `code` and
`status`.

- An insert of `{ $in: ['x'] }` or `{ $in: [], a: 1 }` gets the engine's
`VALIDATION_FAILED` / 400 with `fields[0]` `{ field: 'position', code:
'invalid_type' }`, and nothing is stored.
- `{ a: 1 }` and `{ $foo: 1 }` are refused `reference_not_found`, with
value `'{"a":1}'` / `'{"$foo":1}'`, and no "could not be read" warning
is logged.
- `'{nope_tok}'` and `'{current_user_id}'` are refused
`reference_not_found` as themselves. A catalog row really named
`'{lit_pos}'` is found, and the assignment naming it is accepted.

**Ablations** at `c282e608`; every restore was proven by blob == `HEAD`
and an empty `git diff HEAD`:

| ablation | mutation | result |
| --- | --- | --- |
| O1 | operator stand-down removed | 1 red: `{"$in":["x"]}` answered
`reference_not_found` instead of `invalid_type` |
| O2 | literal lookup removed | 2 red: `{ a: 1 }` and `'{nope_tok}'`
were accepted ("expected the write to be refused, but it succeeded") |
| B1 / C1 / P1 / N1 / N2 / N3 re-run | as before | 2 / 1 / 3 / 3 / 1 / 2
red |

**Verification at `c282e608`**

- plugin-security: 141 files / 2917 → 2920 tests passed; typecheck exit
0.
- `dispatch-gates --commands`: 94 derived, 94 run, all exit 0. The
`--ran` reconciliation reports a derived zero.
- `check:query-options-erasure` holds at 236.
- The diff-scoped issue-citation check: 11 citations, all resolve.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — 8d679c6a Deployed Jan 23, 2026 by vercel[bot]
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants