chore: merge main into percent-scale-3136, regenerating the api-surface baseline (#4442) - #4526
Conversation
…4430) * docs(spec): SystemFieldName says which columns are actually injected The table documented `tenant_id` as "Tenant isolation key" while the column the registry actually provisions is `organization_id` — which had no constant at all, alongside the equally-missing `created_by` / `updated_by`. Two of the seven entries (`user_id`, `deleted_at`) are not injected either, with nothing saying so. Consumers hand-copying a system-field list read this as the injection set and drifted accordingly: cloud#982 found three copies carrying `tenant_id`, `org_id` and `space` between them, none of which any injection site produces, and cloud#979 was one of those copies claiming a business field named `owner` so every seeded row shipped it blank. Additive only — no entry removed, no value changed: - add ORGANIZATION_ID, CREATED_BY, UPDATED_BY, the three injected columns the table was missing; - record per entry whether open-core injects it, so the legacy (`tenant_id`) and authored (`user_id`) names cannot be mistaken for provisioned ones; - state in the module doc that this is a NAME registry, not the injected set — `applySystemFields` decides that per object from `ownership` / `tenancy` / `systemFields`, so the same name is a system column on one object and business data on the next. A consumer asking "is this field system-managed on THIS object" branches on `Field.system`, which is already published for exactly that and which the doc now points at. `@objectstack/lint`'s SYSTEM_FIELDS is unaffected in content: it unions this table with FIELD_GROUP_SYSTEM_FIELDS, which already carried all three added names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YRXTo7QC2A6EXXxJ5GKwsS * chore: add changeset Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YRXTo7QC2A6EXXxJ5GKwsS --------- Co-authored-by: Claude <noreply@anthropic.com>
…et in the TYPE — bare `{type:'user'}` is not targetless (#4438)
* fix(spec,objectql,metadata-protocol): a `user` field carries its target in the TYPE — bare `{type:'user'}` is not targetless
`field.zod` defines `user` as "a lookup specialized to the `sys_user` system
object … target fixed to the `sys_user` system object", and `Field.user()` —
unlike `Field.lookup(reference, …)` / `Field.masterDetail(reference, …)` —
takes NO target argument and writes `reference: 'sys_user'` itself. The target
is a CONSTANT OF THE TYPE. `reference` on a `user` field materializes that
constant; it does not supply it.
Two callers read `field.reference` raw and so disagreed with that definition:
the protocol's expand gate refused `?expand=<a bare user field>` with
`400 INVALID_FIELD … declares no target object`, and objectql's expand loop
skipped it. Metadata authored without the redundant `reference` — hand-written
JSON, an AI author, a Studio form — was therefore read as under-specified when
it was complete.
Live capture (cloud#983): an AI-built equipment app modelled 负责人 as
`{ type: 'user' }`; objectui's default list expanded that column (its
`EXPANDABLE_FIELD_TYPES` keys on the TYPE, deliberately ignoring the target);
the very first screen of the brand-new app rendered "该视图的查询被拒绝" over
that 400.
`referenceTargetOf` in `@objectstack/spec/data` is now the single arbiter of
"what does this reference field point at", next to `REFERENCE_VALUE_TYPES` —
the set the same two callers already share for "is this a reference at all".
Both halves of the expand path read it, which is what stops the gate from
refusing a field the engine would have expanded, or blessing one it skips.
Fixing only the gate would be worse than not fixing it: the request would be
admitted and the engine would still skip the field, answering 200 with a raw
user id in the cell — the "client renders raw ids where names belong" failure
the expand axis exists to close. The conformance test pins BOTH halves (each
was verified to fail alone).
Deliberately unchanged: `seed-loader`'s reference resolution still requires an
explicit `reference`. An unresolvable seed reference is a HARD failure there,
so folding implicit targets in would turn seeds that today write a raw string
into failed loads — a different subsystem's contract question, not this one.
* chore: changeset + regenerate spec api-surface snapshot for referenceTargetOf
---------
Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
… branch label (#4440) * fix(automation): enforce isDefault and stop swallowing an unclaimable branch label (#4414) A `decision` node advertised three ways to split a path and only `edge.condition` did anything. `FlowEdgeSchema.isDefault` had zero readers outside its own declaration, and the `conditions[].label` → `branchLabel` route matched 0 out-edge labels across every example app before falling back to the full edge set in silence. Stacked, the two shipped a guard that does not guard: `crm_convert_lead_wizard` showed an already-converted lead the abort screen AND walked it into the conversion wizard behind it. The three mechanisms now compose as one model in `traverseNext`: `branchLabel` narrows the edge set, `condition` gates each edge, `isDefault` catches whatever is left. - `isDefault` is enforced as the BPMN default flow: traversed only when no conditional sibling matched, and kept out of the unconditional parallel fan-out. Passed over because a real branch won, its target records the same `skipped` step a closed gate does (#4354). - A branch label no out-edge carries is logged instead of swallowed. Traversal still falls back to the full edge set — a run mid-flight must not die on a metadata error — but says which branch was computed and which labels exist. - A decision that declares no `conditions` reports no branch. It used to report `'default'` regardless, a label no out-edge in the repo carried, which is why every decision node fell back to the full edge set. The sentinel survives for the case it describes (declared conditions, none matched) and is now claimed by the `isDefault` edge as well. - `conditions[].expression` is evaluated as the bare CEL it is declared to be. The raw string went to the legacy `{var}` template path, where a dotted reference cannot resolve and the branch is decided by string comparison; a brace-in-CEL predicate now fails loudly (ADR-0032 §1c). Caught at authoring time too, since a wrong route is silent at run time by nature (Prime Directive #12): `flow-branch-label-unmatched`, `flow-decision-unconditional-branch`, `flow-default-edge-with-condition` and `flow-multiple-default-edges`. The first two fire on the pre-fix `convert-lead.flow.ts` and are silent after it. app-crm's guard is now a plain exclusive gateway — the redundant `config.conditions` is gone and `e3b` carries `isDefault: true`, so exactly one branch runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8as8yR67v41xEdomiTba9 * docs(automation): name the #4414 shape in the branching section Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8as8yR67v41xEdomiTba9 * docs(spec): regenerate the flow/decision reference pages after the isDefault contract change `content/docs/references/` is generated from `packages/spec`; the #4414 `.describe()` rewrites left flow.mdx and schemaless-node-config.mdx stale, which is what `check:docs` caught. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8as8yR67v41xEdomiTba9 --------- Co-authored-by: Claude <noreply@anthropic.com>
…kernel-internal cache/queue/job slots (#4318) (#4448) `SERVICE_CONFIG` declared `/api/v1/cache`, `/api/v1/queue` and `/api/v1/jobs` — three paths that existed nowhere else in the repository: no dispatcher domain, no adapter mount, no plugin registration. Nor was one pending: the slots' shipped providers (`service-cache` / `-queue` / `-job`) are in-process contracts that mount no HTTP surface. The kernel pre-injects self-describing in-memory fallbacks into all three on every default boot, so every default deployment emitted a `ServiceInfo` whose `route` said "call me here" next to its own `handlerReady: false` saying "there is no handler". The route is removed at the root rather than suppressed per-occupant: these slots are route-less now, structurally, the way `realtime` already was. What differs from `realtime` is the unmarked case, so each route-less entry states it — `realtime`'s advertised capability IS the missing HTTP/WS surface, so an in-process bus there is `degraded`; a cache/queue/job slot's contract is in-process to begin with, so a real (unmarked) implementation stays `available`. The dispatcher builder had the same defect one field over: `svcAvailable` gave an unmarked occupant `handlerReady: true`, a handler that does not exist. Those slots report through `svcInProcess` now, with `handlerReady` pinned `false`. The explanatory message is written once, as `inProcessServiceMessage()` in `@objectstack/spec/system`, so the two builders cannot drift. Tests pin both builders and, for cache/queue/job, pin them against each other across both occupant shapes. The two service READMEs advertised REST endpoint tables for surfaces that were never mounted; replaced with what is true. Closes #4318.
…ands (#4409) (#4445) * fix(cli): every author-time rule that can gate runs on all three commands (#4409) `os validate`, `os build` and `os lint` each hand-wired their own subset of the author-time rules. Nothing connected the three lists, so "which rules run here?" was answerable only by diffing three 800-line files by eye — and the answer drifted every time a rule landed. The audit found 23 of 26 rules running on some strict subset, nine of them able to emit `error`. The worst direction was the least obvious: `os build` — the command that PUBLISHES — was the weakest gate of the three. A flow whose expression approver does not parse (`approval-expression-invalid`) built and published green; only `os lint` stopped it, and CI usually runs the other two. This is the same failure mode's fifth appearance (#3583, #3782, #4384/#4394, #4402). Each earlier repair removed an instance and left the MODE: a rule's command coverage was whatever its author remembered to type, and forgetting was silent. #4402's guard could not catch the rest — it filtered on the current member names of one suite, so a rule hand-wired into two commands from outside that suite passed it without a word. Replace remembering with a table: - `packages/cli/src/lint/authoring-rules.ts` declares all 26 rules as data — tier (gating/advisory), which stack tier they read (pre-parse vs parsed), which commands run them, and a written reason for the one narrowing. All three commands consume it through `runAuthoringRules()`; the three command files shrink by ~1000 lines between them. - `authoring-rule-wiring.test.ts` upgrades the guard from a name list to a ratchet: a gating rule on fewer than three commands fails, a narrowed rule with no reason fails, a command that calls a rule directly fails, and an `advisory` claim is checked against the rule's own source so a gate cannot wear an advisory label to buy partial coverage. Remaining direct calls are listed with reasons in `DIRECT_CALL_RATCHET` / `LINT_IMPORT_RATCHET`. - `authoring-rule-command-parity.test.ts` proves the verdict, not just the wiring: one case per previously-blind gating rule, plus the issue's own repro driven through the real CLI — exit 1 on all three commands where it was 1/0/0 before. Two things fall out of one report per run rather than exiting at the first failing gate: an author with three unrelated problems sees all three, and `--strict` now covers every advisory instead of the roughly half that happened to be printed inline. Also closes the same hole one gate over: `collectAndLintDocs` failed `os build` and never ran on `os validate`, invisible because the parity guard keyed on the `lint*`/`validate*` naming convention and that gate is called `collect*`. The guard now names each shared non-registry gate explicitly. Cost is not what argued against this: the heavy deps (typescript, sucrase) are already lazy, and the heaviest rule of the set has run on all three commands as a suite member since #4340 without anyone noticing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sz61GE1CWCSnGW4qShSEXs * chore(changeset): author-time rule command-coverage registry (#4409) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sz61GE1CWCSnGW4qShSEXs * docs: name the ADR-0085 pointers by field, not by the reserved word (#4409) The role-word ratchet (ADR-0090 D3) counts occurrences per file, and two doc lines describing `validateSemanticRoles` reintroduced the banned word for a non-permission concept. Name what the rule actually checks instead: `stageField` / `highlightFields` pointers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sz61GE1CWCSnGW4qShSEXs --------- Co-authored-by: Claude <noreply@anthropic.com>
…DME (#4443) The Batch Options section documented `validateOnly` as a working dry-run — "validate records without persisting changes" — but the key was retired in #4052 precisely because nothing ever read it. Every batch surface (updateManyData / deleteManyData / batchData) persisted regardless, so a caller who sent it to preview a mutation got that mutation executed. BatchOptionsSchema has carried a retiredKey(...) tombstone since #4052, so the schema already refuses the key loudly. The README was the last place still promising it — declared-but-not-enforced in prose rather than in code, aimed at exactly the readers who cannot see the tombstone. Replaced with a pointer to docs/protocol-upgrade-guide.md (batch-options-validate-only-retired). No behaviour change; there is no batch dry-run today. Shipped as a patch because README.md is in this package's `files`, so the correction only reaches npmjs.com readers if it releases. Found while evaluating #4372 (write-path validate-only mode), closed as not planned — no current consumer justifies the surface.
…e, not from the caller (#4453) `AutomationEngine.evaluateCondition` picked its engine by asking whether an `{ dialect, source }` envelope was present, so a condition handed to it as a plain string never reached CEL — it fell to the legacy `{var}` template path and both sides were compared as text. `existingTask == null` became `'existingTask' === 'null'` (always false); `record.rating >= 4` became `'r' > '4'` (always true). Both reported success. #4414/#4440 fixed the one built-in reaching this by wrapping at the call site. This fixes the evaluator: the dialect is read from the source, and a condition is CEL unless it actually contains a `{var}` hole. `evaluateCondition` is public API, so a plugin-registered executor was still getting the old behaviour. The `{var}` dialect keeps working and gains what it was missing: a quoted literal compares as its contents (`{status} == 'active'` was false for every value), and its two silent-`false` exits — an unresolvable `{…}` hole, and a substituted value that is neither boolean, numeric, nor part of a comparison — are refused with the source attached (ADR-0032 §1c). Braces inside an explicit `dialect: 'cel'` envelope remain the #1491 brace-trap. The sniff skips string literals, so `record.label == '{pending}'` stays CEL. Closes #4336. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…#4413) (#4450) react 档契约在 <RecordDetails> / <RecordHighlights> / <RecordRelatedList> / <RecordPath> 上 publish 了 objectName / recordId,没有任何渲染器读这两个 prop。 十个 record:* 渲染器全部从 useRecordContext() 取记录,而该 context 只有记录路由 (RecordDetailView) 和元数据编辑器预览 (PagePreview) 挂载;react-page 只把页面包进 SchemaRendererProvider。完全照契约写出来的页面渲染成空,且全链路无报错——包括 os validate,它还在把这些 prop 的字段名解析到它们命名的对象上。 撤回而非实现:这份契约的形状本身就是错的。每 block 各带绑定描述的是"四个 block 各取一次同一条记录",恰是共享 record context 存在的目的所要防止的耦合方式 (record:details 会去重已挂载 record:highlights 注册的字段;内联编辑保存栏用一个 ifMatch 提交整份草稿)。照 props 修渲染器等于把错误形状固化进契约 (Prime Directive #12)。 该原语要不要有公开名字 (一个作者包在外层的 record SCOPE) 另行由 #4444 记录。 - spec: 四个 block 出 REACT_BLOCKS,留下写明原因的 ledger 和每类型的可用替代。 族成员从 ComponentPropsMap 派生,新增 record 组件当天即被门禁覆盖。 - lint: 新增 react-block-needs-record-context (error),按 tag 与 <Block type="record:…"> 两路都拦,报错直接给出能用的写法;作者本地声明的同名 组件会 shadow 注入 scope,不拦。 - showcase: Renewals Pipeline 改用真能跑的绑定 (<ListView filters> / <ObjectForm mode="view">)。 - 契约、skill、文档同步重生成或订正。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015hz1t4rGsHvFTZYWRnRNPD
* feat(spec,service-datasource): datasource.config is parsed against its driver's contract (#4410) `config` was the one authorable slot on a datasource with no gate at all. The module comment justified the hole by saying "the driver's own `configSchema` is what validates it". Nothing did: both bundled driver specs set `configSchema: {}`, no code read the field, and the per-driver zod schemas were not exported from the package — `data/driver/` was reachable only from its own tests. So `config: { hostname: 'db.internal' }` (the key is `host`) was accepted in silence and the datasource connected to localhost while the parse, the save and the connection probe all reported success. That is #4001's original bug verbatim, one level down, and #4001's own fix pointed authors straight into it. The frontend question the issue raised has an answer, and it is not a third false claim: objectui's DatasourceResourcePage really does render the connection form from a driver `configSchema` (`GET /api/v1/datasources/drivers`, reading properties/required/title/format). It reads DRIVER_CATALOG — a SECOND set of hand-written JSON-Schema literals in service-datasource, never checked against the spec's zod schemas and never validating anything. One live copy, one dead copy, no gate between them. So: `packages/spec/src/data/driver/` becomes the one contract, and three consumers read it. `DatasourceSchema` parses `config` — and each `readReplicas` entry — against the schema for the declared driver; `DriverDefinitionSchema .configSchema` publishes its JSON-Schema projection; the catalog serves that same projection, so the form offers exactly the fields the validator accepts. `mysql` and `sqlite` / `sqlite-wasm` had no config shape anywhere, though both were offered by the form and buildable by the factory. The wizard is the other authoring door and does not reach DatasourceSchema: createDatasource writes through `metadata.register`, whose validation is a structural name/label check. DatasourceAdminService create/update/test now consults the same registry — testConnection BEFORE probing, or a green "connection successful" gets reported against localhost. Enforcing the contract forced honouring it. A gate over `config` means every key inside it claims to be read, so each was audited against the code that reads it: - `datasource.pool` reaches every SQL driver. It was declared, strict, carried into the connection spec — then overwritten with a hardcoded { min: 0, max: 5 }. Maps onto minPoolSize/maxPoolSize for mongo. - `datasource.schemaMode` reaches the driver. It was dropped between the record and the spec, so the factory looked for it in two places that could never hold it and an `external` database — one ObjectStack must never run DDL against — was constructed as `managed`. - `datasource.ssl` reaches the SQL clients, certificates and all. It stopped at the record, so a TLS block configured nothing: the failure its own schema comment warns about. - postgres `schema` (knex searchPath), `applicationName`, `statementTimeout`. - mongo `password`, `authSource`, `options`. A mongo datasource carrying a `config.password` composed its URL with an EMPTY password. Two memory keys had nothing to wire to — `InMemoryDriverConfig` has no field for `indexes` or `maxRecordsPerObject`, the driver keeps no indexes and evicts nothing — so they are removed under ADR-0049 with the rejection carrying why. `config.ssl` is the boolean shorthand only, deliberately. A `boolean | object` union is honest about what the client accepts, but the form turns anything that is not boolean/enum/number into a TEXT INPUT: the wizard would have produced a string the new gate rejects. Certificates go in the datasource-level block, which this change makes live. One table for driver ids, in the spec. The factory kept its own copy, which meant the id selecting a DRIVER and the id selecting that driver's CONFIG CONTRACT could disagree — the same silent acceptance, reintroduced as a lookup miss. Also fixes the docs generator's one-level-deep source walk, which filed the new schemas onto a `misc` page whose "Source" line named a file that does not exist (the identical bug the strictness ledger's own coverage gate had). The recursive walk gives `data/driver/`, `integration/connector/` and `kernel/events/` real per-file pages; no documented schema was lost, six more are now covered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WsgTqRF58HsQYKLsrZ5pQY * test(spec): pin the ssl split the config gate forced `config.ssl` narrowed to the on/off shorthand when the connection form turned out to render a non-boolean/enum/number prop as a TEXT INPUT — a `boolean | object` union there would have produced a wizard whose every `ssl` value the new gate rejects. These fixtures still passed the object form, so they asserted a shape the contract no longer has. They now assert the prescription instead: the certificate-bearing form is rejected and named toward the datasource-level `ssl` block, which #4410 wired through to the client. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WsgTqRF58HsQYKLsrZ5pQY * docs(spec): regenerate driver-sqlite reference after the comment fix The generated page carried the pre-fix wording of the module comment — the `check:docs` gate caught it on the post-merge re-run, which is what that gate is for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WsgTqRF58HsQYKLsrZ5pQY --------- Co-authored-by: Claude <noreply@anthropic.com>
…ough the param gate (#4461) objectui 17.1 (objectui#3141) adds an aggregate single-call mode for bulkActionDefs: an execution: 'aggregate' entry dispatches its named object action ONCE for the whole selection, with every selected id injected as params._selectedIds. This is the server-side contract half. - ACTION_PARAM_BUILTIN_KEYS += '_selectedIds' so the ADR-0104 strict gate does not 400 an aggregate dispatch against a param-declaring action — dispatcher-injected, never authorable, pure widening (+ pin test) - bulkActionDefs describe documents the aggregate contract: read params._selectedIds (not recordId), all-or-nothing results, batchSize N/A, maxRecords advised, ${ctx.selection.ids} toolbar interpolation - content/docs/ui/views.mdx documents bulkActionDefs for authors for the first time; references regenerated - liveness view.json evidence now cites the dispatch path - showcase: showcase_recalc_selection specimen next to the per-record fixtures; recalc endpoint gains a _selectedIds batch branch; zh-CN translations for both recalc surfaces
…, and executes every option it declares (#4419) (#4459) findOne reads a single row, which makes its predicate the only thing between the caller and an arbitrary record. When the predicate is missing the result is not `null` — it is the object's FIRST ROW: a real, plausible-looking record with nothing to do with the request, which the `if (!row)` check every call site already has cannot catch, and which then propagates into whatever is computed next. #4419 reported this against the `filter` key, which #4346 (fold on every entry point) and #4400 (unknown keys throw) already closed. This is what those left standing. BREAKING: findOne refuses a query that selects nothing in particular. findOne(o) / findOne(o, {}) / findOne(o, { where: {} }) -> findOne(o, { where: … }) the record matching this -> findOne(o, { search: 'Acme' }) the record this search finds -> findOne(o, { orderBy: [{ field, order }] }) the FIRST in this order -> find(o, { limit: 1 }) any row will do, said aloud The error names all four. `find` and `count` are unchanged — returning or counting every row is an honest answer. The guard reads the CALLER's predicate, before RLS/sharing middleware injects its own. Two silent drops that produced the same wrong record are fixed with it: - findOne({ search }) now applies the search. The ADR-0061 expansion lived in find() alone while both methods are checked against the SAME legal-key set, so `search` passed the gate, reached a driver that does not read it, and the read ran unpredicated. - MongoDBDriver.findOne now applies orderBy, fields and offset. It translated `where` and dropped the rest, so "the newest record" returned whichever document the scan reached first. No ordering is imposed when the caller supplies none (#4363) — unchanged on both drivers. And a drift pin walks ENGINE_OPTION_KEY_SETS.findOne requiring each declared key to have an observable effect, so the next declared-but-unexecuted key fails CI instead of shipping. The Mongo cases live in one shared table read by both a server-free options suite and the real-mongod suite, so neither half can drift.
…sys_metadata rows in place (#4327) (#4464) * feat(migrate,metadata-protocol): os migrate meta --stored rewrites sys_metadata rows in place (#4327) #4317 closed the correctness gap from the read side: every stored-row rehydration seam replays the full ADR-0087 conversion chain, retired entries included, so a row written under any past protocol is served canonical forever. The rows themselves stayed legacy — the chain re-lowers them on every load and each logs a conversion notice per process. Until now the only things that rewrote such a row were a Studio re-save and duplicatePackage. `os migrate meta --stored` walks sys_metadata (active + draft, all orgs), replays the same applyConversionsToStoredItem pass, and re-saves each changed body through saveMetaItem — so a rewritten row gets a sys_metadata_history entry, a fresh checksum and the mutation projectors, exactly like an author's save. The history row's source is `migrate-stored`, distinguishing an upgrade from an edit. parentVersion is the row's own checksum, so a concurrent writer produces a 409 the report names rather than a clobber. Preview is the default and --apply the only writing mode, matching its two siblings and #3617's "a dry run changes nothing"; an apply run refuses to start while another process holds the SQLite database. Nothing gates on this having run (#3855) and no sys_migration flag is recorded — a flag would advertise enforcement that does not exist. What a run buys is hygiene plus an assertable verdict: nothing left to do exits 0, work remaining exits 1. Three carve-outs are reported rather than counted as done: flow rows (their seam is AutomationEngine.registerFlow, which holds the executor registry the node-type conflict guard needs), types with no repository write path (agent), and rows that still fail the current schema after conversion. An empty scan says it attests nothing rather than reading as a pass. Also: protocol.migrateStoredMetadata() returns the same structured report an admin route would render, and saveMetaItem takes an optional `source` for its history/audit rows — server-stated, never request-derived. Closes #4327 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WoZPKPDqJ7WB7z84xk9y3f * docs(adr-0087): name the follow-up that gives flow rows the same finish line (#4454) The addendum said flows were "tracked separately" without saying where. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WoZPKPDqJ7WB7z84xk9y3f * docs(releases): name the stored-metadata pass on the v17 page (#4327) The v17 entry for #3903 described the read-path guarantee and stopped there, and the upgrade checklist listed the two per-deployment migrations without this one. Both now point at `os migrate meta --stored`, marked optional — it opens no gate, unlike its two neighbours in that list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WoZPKPDqJ7WB7z84xk9y3f --------- Co-authored-by: Claude <noreply@anthropic.com>
* fix(automation,approvals): an approval decision can no longer succeed while its flow stays parked (#4420) A flow paused at an `approval` node, a deploy, then an approver clicking Approve: the request row flipped to `approved`, the UI toasted success — and the flow never moved. No next-stage request, no error, the record's mirrored status frozen mid-workflow. Approval flows pause for days by design, so a restart mid-flight is the normal case, not the edge one. Durable suspended runs (#1518) had shipped and were not the missing piece. Two other things were. The wiring could enable a store over a table nobody had created. Object registration and store activation resolve different services in different phases — `manifest` at init(), `objectql` at start() — and the plugin declared no ordering. Composed ahead of ObjectQL, init() found no `manifest`, warned, and continued; start() attached the DB-backed store anyway. Every suspend then failed with `no such table: sys_automation_run` into a log line nobody read. Now: `optionalDependencies: ['com.objectstack.engine.objectql']` (order-if- present per ADR-0116 — an engine-less kernel must still boot), a missed registration retried at start() while it still precedes schema sync, no store attached when registration did not happen (at error level), a boot-time table probe, and a failed durable write logged at error rather than warn. A reported resume failure read as success. `AutomationEngine.resume()` answers a lost run by RETURNING `{ success: false }`, never by throwing; approvals discarded that value and counted only throws as failure. Resume failures are now classified — RUN_NOT_FOUND, STORE_UNAVAILABLE, RESUME_IN_PROGRESS — so a run gone for good is distinguishable from a store merely unreachable, and the raw resume route maps them to 404 / 503 / 409. Approvals acts on them. The new `hasSuspendedRun(runId)` — which reads the suspension store, unlike `getRun()`, and throws rather than answering false when the store is unreadable — pre-flights decide / sendBack / resubmit BEFORE their first write, so the zombie half-state is never created rather than merely reported: RESUME_TARGET_LOST (409), request left actionable. A resume that fails after the decision is durable throws RESUME_FAILED (500) naming the stranded run. A concurrent duplicate stays benign via the new `resumeError` field; recall and revise-window cancellation stay non-fatal but log at error. Compositions with no automation engine attached are unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EPRaZTNbACfvGKytYESX2h * docs(spec): regenerate the API reference for the new resume error codes (#4420) `check:docs` compares `content/docs/references/` against what `gen:schema && gen:docs` produces. The five codes this branch registers in the ADR-0112 ledger — RUN_NOT_FOUND, STORE_UNAVAILABLE, RESUME_IN_PROGRESS, RESUME_TARGET_LOST, RESUME_FAILED — feed `ErrorCode`, which the generator inlines into every response-envelope table. Hence eleven files, all of it the same union widening: the ledger page gains the five entries and every page that renders an ApiError picks them up. Generated, not hand-edited: pnpm --filter @objectstack/spec gen:schema && pnpm --filter @objectstack/spec gen:docs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EPRaZTNbACfvGKytYESX2h --------- Co-authored-by: Claude <noreply@anthropic.com>
…西打开过副本连接 (#4468) (#4481) * docs(agents): claim the issue before writing any code Several agents work this repo at once and an unassigned issue reads as an open invitation. Two agents starting the same issue burn the same hours twice and then race to land conflicting shapes for one problem. Makes assignment the first action of a task — before the worktree, before the first read — and states the corollary for Prime Directive #10: file a finding unassigned, assign it at the moment you actually start, so the issue list works as a queue other agents can trust. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WsgTqRF58HsQYKLsrZ5pQY * feat(spec)!: retire datasource.readReplicas (#4468) BREAKING CHANGE: `datasource.readReplicas` is removed. It described replica connections nothing ever opened. `ConnectableDatasource` and `DatasourceConnectionSpec` carry no replicas field, the driver factory never reads the key, and no query path distinguishes a read from a write — the platform has no read/write splitting at all, so every statement always went to the primary regardless of what was declared. There is no target to move to, because there is no read-replica routing to move to. Front replicas behind one endpoint (pgpool, ProxySQL, an RDS reader endpoint) and point `config` there; `os migrate meta --from 16` strips the key. Worth recording why this one survived so long: #4410 closed the `datasource.config` gap and, reasonably, extended the new per-driver validation over each `readReplicas` entry. The result was a slot with every marker of a working feature — declared, `.strict()`-guarded, and validated field-by-field against the driver's contract, rejecting a misspelt replica host by index. None of that is evidence of a consumer and all of it reads like one. Rigor is cheap to add to a dead slot and expensive to tell apart from life, which is why ADR-0049 asks for a consumer rather than for rigor. The retirement kit: - DatasourceSchema: key deleted (strict route), `readReplicas`/`replicas` added to the guidance map so the rejection carries the prescription; the #4410 validation loop removed with it - `datasource-read-replicas-removed` D2 conversion + step-17 chain wiring, retired from the load path like the other keys retired for misdescribing themselves - authorable-surface baseline line deleted deliberately (gate (a)'s strict- removal trip wire); spec-changes, upgrade guide, reference docs regenerated - pin test flipped from "validates each entry" to "rejects the slot" - release notes, strictness ledger, and the two pending changesets that still described the key corrected Follow-ups filed: #4479 (read-replica routing as a real feature request, starting from the read/write decision point) and #4480 (the same feature declared a second time as `DatabaseConnector.readReplicaConfig`, also unread). Closes #4468 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WsgTqRF58HsQYKLsrZ5pQY --------- Co-authored-by: Claude <noreply@anthropic.com>
…raphql 残留 (#4451) (#4473) * refactor(spec,client,metadata-protocol,runtime)!: retire the workflow service slot and the stray graphql entry (#4451) The `workflow` slot was ADR-0078's silently-inert declaration at every layer at once, and had been since it was written: a `CoreServiceName` nothing ever registered or resolved, an `IWorkflowService` contract with zero implementations, a `WorkflowProtocol` whose three methods no code ever provided, an `ApiRoutes.workflow` field no builder could truthfully populate, and an `/api/v1/workflow` advertisement for a path no host ever mounted. The pre-#3586 `DEFAULT_DISPATCHER_ROUTES` already listed that path among "routes that never existed"; ADR-0115 Evidence 5 verified the slot itself across both repositories — "no code in this repository resolves either slot", the only touches being plugin-dev's since-retired stub probe and the generic discovery walk. Nothing here is being taken away from anyone, because the capability the slot promised has been live elsewhere for majors: record state machines are enforced by the `state_machine` validation rule (`StateMachineSchema` stays authorable on the object), approvals are first-class flow nodes on the approvals runtime (ADR-0019 folded the standalone approval process into Flow), and record-triggered automation is lifecycle hooks + `record_change` flows. That is why this is a removal rather than an enforcement: there is no feature to build, only a second name for three that exist. Removed with it: the `graphql` entry in `CORE_SERVICE_PROVIDER` and the `graphql: { route: '/graphql' }` discovery entry. `graphql` was never a `CoreServiceName` — so nothing could occupy the slot and the entry was unreachable — and it declared a path the dispatcher had already dropped as out of the product plan (#2462 follow-on). The provider guard only checks that every SLOT has an entry, never that every entry is a slot, which is how the stray sat unchallenged. Direct cut inside the 17.x rc window, per ADR-0115 D5. The retirement kit: a `workflow-service-slot-retired` SemanticMigration on the major-17 step carries the FROM -> TO into spec-changes.json, the generated upgrade guide and the `spec_changes` MCP tool. These are TS/API surfaces and discovery RESPONSE fields — never stored in stack metadata — so there is no load-path conversion and nothing for `os migrate meta` to rewrite. The 21 `authorable-surface.json` baseline lines and 7 `json-schema.manifest.json` entries are dropped deliberately in the same change, following the plugin-runtime precedent: a `retiredKey()` prescription earns its keep at a parse the author reaches, and nothing parses these shapes any more. `os explain workflow` is kept as a redirect topic rather than deleted, mirroring content/docs/automation/workflows.mdx. It had been teaching a shape the spec never had (`states[]` / `transitions[]` / `approvers`); it now names the three live mechanisms instead. * docs: retire the workflow slot from four more hand-written pages (#4451) The docs-drift check on PR #4473 earned its keep: my symbol-shaped grep (`IWorkflowService`, `WorkflowProtocol`, `api/v1/workflow`) found three pages and missed four PROSE mentions that describe the same retired slot in words. - `api/plugin-endpoints.mdx` documented three `/workflow/*` routes under a "not yet mounted … return 404 today" caveat. The caveat was already the tell: routes that 404 for the whole life of the declaration are not "not yet", and the slot behind them is gone now. The section becomes a redirect naming the three live mechanisms. - `kernel/services-checklist.mdx` carried it in three more places — the legend's 36-method count (now 33), the `null`-provider explanation, and a full "6. workflow Service" section still describing the three methods as pending rather than removed. The remaining `workflow` hits in `content/docs` are the ordinary English word (approval workflow, build workflow, GitHub Actions workflows) and stay. --------- Co-authored-by: Claude <noreply@anthropic.com>
…11 names declared twice with different shapes (#4411) (#4458) * refactor(spec)!: remove the kernel metadata-loader envelope family — 11 names declared twice with different shapes (#4411) `@objectstack/spec` exported eleven names TWICE, with a different shape each time, on two subpath entries — so which type a consumer got depended on nothing but the import path: import type { MetadataWatchEvent } from '@objectstack/spec/kernel'; // one shape import type { MetadataWatchEvent } from '@objectstack/spec/system'; // another `MetadataFormat`, `MetadataStats`, `MetadataLoadOptions`, `MetadataSaveOptions`, `MetadataExportOptions`, `MetadataImportOptions`, `MetadataLoadResult`, `MetadataSaveResult`, `MetadataWatchEvent`, `MetadataCollectionInfo` and `MetadataLoaderContract` are removed from `kernel/metadata-loader.zod`. The `system/metadata-persistence.zod` copies stay as the single source. Why the kernel side goes, and why this was worth removing rather than living with: - Zero consumers. Import-statement scans across this repo, `cloud` and `objectui` find every consumer on `./system` (or `./contracts`' own interface); only `kernel/metadata-loader.test.ts` ever parsed the kernel copies. ADR-0049 enforce-or-remove. - The naming intuition pointed the wrong way, which is what made this sharper than an ordinary duplicate. The kernel copies were the ones that LOOKED canonical — normalized enums, required fields, a `.describe()` per property — and they were the dead ones; the live copy is the loose superset its own consumer calls "legacy". Picking by name, or by which reads as more rigorous, picked the dead one, and because the shapes overlap heavily that choice compiled and failed later, at an edge value (`add` vs `added`) or on a field one copy made required. No tombstone and no ADR-0087 conversion, deliberately: these are runtime envelope types, not authorable metadata, so no authored source can carry them and there is nothing for `os migrate meta` to rewrite (the plugin-runtime / dev-plugin precedents). `MetadataManagerConfig` and `MetadataFallbackStrategy` are untouched — they were never duplicated (kernel owns them, system re-exports them), and that is the split that survives: manager wiring is kernel's, the loader/watch envelope is system's, nothing is declared twice. `MetadataManagerConfig.formats` now reads the `shared` format enum (same four members, leaf module, no cycle) rather than a fourth local copy. Also: - `contracts/metadata-service.ts` drops the "spec carries TWO types named MetadataWatchEvent" warning added in #4404 — it no longer does. - `expression-conformance.ledger.ts` drops the now-absent `kernel/metadata-loader.zod.ts:filter` CEL surface (the surviving system options never declared a `filter`, so no loader predicate was ever evaluated through it). - Baselines dropped deliberately: `json-schema.manifest.json` −11 entries, `authorable-surface.json` −65 lines (nothing can author these, so no `[RETIRED]` markers). `api-surface.json` regenerated: 22 exports leave `./kernel`. `references/kernel/metadata-persistence.mdx` removed by `gen:docs`. v17 release notes + upgrade checklist extended. No runtime behaviour changes — nothing read the removed copies. The system shapes are NOT tightened here; narrowing them would be a separate change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M9uWvoEp9CoLzYjNExj9sL * chore(spec): write the hand-edited baselines the way the generator does The two baselines this branch edits by hand — `json-schema.manifest.json` and `authorable-surface.json` — came out with `—` escaped as `—`, because the edit went through Python's `json.dump`, whose `ensure_ascii` defaults to true. `build-schemas.ts` writes them with `JSON.stringify`, which emits the character literally. No gate catches this: the manifest is only rewritten when the SCHEMA KEY SET changes, so the escape would have sat in the file until the next PR that adds a schema, where the generator would silently rewrite it back and hand that author an unrelated one-line diff to explain. Re-serialised with `ensure_ascii=False`. Both files now differ from main by exactly the intended removals and nothing else: 11 manifest keys, 65 authorable-surface lines, zero incidental churn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M9uWvoEp9CoLzYjNExj9sL --------- Co-authored-by: Claude <noreply@anthropic.com>
…ype (#4487) (#4489) `GOVERNED` was a hand-maintained list and nothing compared it against the registry it claims to cover. It governed 15 of 25 registered metadata types while reporting itself complete: a type in the other ten was authorable — served by /api/v1/meta/types/:type, editable in Studio — and was never asked who reads its properties, so an inert key on it was invisible to CI and its silence read as success. `datasource` was in that state for its entire life. #4410, #4465 and #4481 found six inert keys on it by hand, two security-shaped: `schemaMode` was dropped between record and connection spec, so a database ObjectStack must never run DDL against was constructed as `managed`; `ssl` stopped at the record, so a TLS block with a CA certificate configured nothing while looking identical to one that worked. The gate is now answerable to the registry. Every registered type must be in GOVERNED or in PENDING_GOVERNANCE with a reason and an issue; registering a type and forgetting the ledger fails CI with the entry to write. The reverse rots too and also fails — a PENDING_GOVERNANCE row for a type since governed claims a debt that no longer exists. `datasource` is now governed: 43 properties classified with evidence, and the result is the highest dead ratio of any governed type — 20 of 43 have no runtime consumer. `capabilities.*` (11): the engine gates pushdown on the runtime driver's own `supports.*` object, a non-overlapping vocabulary. `healthCheck.*` (3): nothing schedules a datasource probe. `retryPolicy.*` (4): no connect or query path retries. Plus `external.label` and `external.requirePermission`. One correction ships with this, and it is why the audit was worth doing. `capabilities.readOnly` reads as a safety switch and gates nothing — and two shipped prescriptions pointed authors at it: the externalSettingsUnknownKeyError guidance in datasource.zod.ts and the #4465 changeset's relocation table. Both now name `external.allowWrites: false`, the write gate the engine checks. The v17 release notes carried a matching false claim about `capabilities` gating pushdown; corrected here too. The CLI advisory lint picks the ledger up, so `os compile` warns an author who sets any of the 20. That needed `datasource` in TYPE_COLLECTIONS: coverage grows by marking entries authorWarn only WITHIN a type the lint already walks, and a governed type whose collection is unregistered has a correct ledger that warns nobody. Nine types remain ungoverned and are now enumerated rather than implied (#4488). Claude-Session: https://claude.ai/code/session_01WsgTqRF58HsQYKLsrZ5pQY Co-authored-by: Claude <noreply@anthropic.com>
…erge_group triggers (#4490) * docs(agents),ci: release notes are release-owned, scoped re-verify, merge_group triggers Three multi-agent throughput fixes from one PR's merge history (#4458 went three full green CI cycles without landing — main merged 18 PRs in the same 6 hours, and two of its three conflicts were rows racing into the same release-notes table): 1. `content/docs/releases/` is RELEASE-OWNED — never edited in a code PR. Release notes are compiled centrally at release time from changesets + the ADR-0087 registries; a per-PR appended row made `releases/v<major>.mdx` the repo's hottest conflict magnet. Inlined in CLAUDE.md (the must-never-miss set), added to the AGENTS.md Documentation Guardrails table, and the spec-property-retirement skill's checklist item — the one instruction that explicitly sent agents into that file — now routes through the changeset instead. 2. AGENTS.md multi-agent §10 scopes the post-merge re-verify. The first pull-main-and-full-suite round stays. Subsequent merges done only because main moved during CI: rebuild + `check:generated` when spec moved on either side, assert the branch's delta vs main is still exactly the PR's intent, and reserve the full typecheck+test for semantic overlap or non-mechanical conflicts. CI validates the merge commit either way; a 15-minute full lap per merge is what turns a busy main into a livelock. 3. `merge_group:` triggers on the three required-check workflows (ci.yml, lint.yml, spec-liveness-check.yml), so the repo can turn on GitHub's merge queue — the race-free version of §10, run by the platform. ci.yml details: the paths filter has no merge_group support, so queue builds treat everything as changed (a skipped filter step's empty output falls back to 'true'), and the full-suite step runs on queue builds (the queue result IS the next main). §7 now names the queue as the sanctioned path once an admin enables it — the opposite of the auto-merge it bans, since the queue lands only speculatively-merged-and-green results. pr-automation and docs-drift-check are deliberately NOT queue-triggered (PR-context-bound; must not be marked required). Enabling the queue itself is a branch-protection setting only an admin can flip; this commit makes the workflows ready for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M9uWvoEp9CoLzYjNExj9sL * chore: empty-frontmatter changeset — this PR releases nothing The Check Changeset gate requires each PR to declare its release impact; the sanctioned "releases nothing" declaration is an empty-frontmatter changeset (per the gate's own inline doc), which a docs+workflow-only PR is exactly the case for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M9uWvoEp9CoLzYjNExj9sL --------- Co-authored-by: Claude <noreply@anthropic.com>
… (#4474) * fix(spec): let a schemaless node own an expression-ledger entry (#4439) `FLOW_NODE_EXPRESSION_PATHS` (#4027) tells `registerFlow` and `objectstack validate` which config keys hold expressions and in which dialect. Its ratchet derives what it expects from descriptor `configSchema` markers and fails both ways — an undeclared marker, and a ledger entry nothing declares. `decision` / `script` / `subflow` publish no descriptor `configSchema` on purpose (a partial one would drop the editors their hand-written Studio forms need — the #4210 incident). Those two rules compose into a hole: an expression slot on a schemaless node is unreachable by the ratchet, and the reverse direction then refuses to let it be entered by hand either. `decision.conditions[].expression` sat in that hole. Its own schema calls it a bare CEL predicate and its own comment names `{…}` as the #1491 trap, and no validator walked it — so a braced predicate passed tsc, passed validate, passed registration. #4414 made that loud at run time; this makes it a build failure, which is the delay #4027 exists to remove. The ratchet now reads both declaration channels. Spec hands the schemaless one over as JSON Schema (`getSchemalessNodeConfigJsonSchemas()`, memoized, input mode — the shape a descriptor's `configSchema` already is), so both are walked by the same function: no second notion of "a declared expression property", and no `zod` dependency added to service-automation. Each channel is separately asserted non-empty so a broken derivation cannot hide behind the other's results. Swept the rest of the class and recorded the result in the ledger header: `script.template` is a template id, not a body; `script.inputs` / `script.variables` / `subflow.input` interpolate `{token}` like essentially every node config string and are covered generically. The decision predicate is the only genuinely declared expression slot there. Docs: the flows guide taught the wrong dialect for decision predicates in three places, plus a warning that inverted after #4414 — and FlowNodeSchema's own `@example` did the same. Corrected to bare CEL with the history stated, so an author whose build now fails knows what changed. The dialect table goes from three dialects to two: predicates never take braces, values always do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8as8yR67v41xEdomiTba9 * docs(automation): state the stored-flow impact, and pin the shape Studio emits Two gaps found while verifying #4439. The #4414 changeset described the mechanism but not who it moves. `objectui`'s FlowEdgeInspector has always written `isDefault: true` when an out-edge is bound to a decision's default/else branch — into a key with zero readers, so that edge ran unconditionally alongside whichever branch matched. Enforcement therefore changes STORED flows, and mostly Studio's own: they now take exactly one branch. That is the fix, but it lands on existing data, which a reader of the release notes needs to know before upgrading. The same inspector also copies each branch's expression and label onto the edge it wires, so Studio emits the very double declaration the authoring guide told hand-writers to avoid. It routes correctly — the designer keeps the two sides in sync by construction — so the guide now says that plainly instead of forbidding a shape our own tool produces: redundant, not wrong, and dangerous only when the two disagree, which is the whole of #4414. Pins that exact emitted shape as a regression test, both directions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8as8yR67v41xEdomiTba9 --------- Co-authored-by: Claude <noreply@anthropic.com>
#4491) 它的文件头原话是"confirms the objectui components ACTUALLY implement the props the spec protocol declares"。它做不到,也从来没做过。它 diff 的两边都是**声明**:左边是 spec zod schema 的 props,右边是 objectui 注册表配置声明的 inputs——由 `manifestFromConfigs` 原样抄进 sdui.manifest.json。整条链路里没有渲染器。所以一个 **两边都声明、没有任何东西读**的 prop,在这道门禁眼里是完美一致。 #4413 就是代价:四个 record:* 块发布了没人读的 objectName/recordId,在 kind:'react' 页面上渲染成 "bind a record to preview" 占位符,而 baseline 里 `{ "frontendOnly": [], "missing": false }` 稳稳绿了整个缺陷存续期。最后是人肉读 objectui 渲染器发现的。**一道报绿的假门禁比没有门禁更危险**——没有门禁时人会去核对。 Prime Directive #10(declared ≠ enforced)落在本该抓 #10 的东西自己身上,和 #1475 「spec 声明 9 种校验规则、执行器只认 3 种」同形。 - 改名到它真正做的事,名字和文件头一起改(名字本身是误信任的一部分): check-react-blocks-declaration-parity.ts、react-declaration-parity.baseline.json, baseline 键 frontendOnly → registryOnly(是"注册表声明了",不是"前端实现了")。 - 作用域说明随**每一次**输出走,包括绿的那次。对这道门禁形成判断的人读的是 CI 日志, 不是文件头。 - 它现在真的 gate。gen-sdui-manifest.sh 调用时没传 --strict,还把退出码吞进一个 ⚠, 所以连它看得见的那部分也只是被记录、从未被拦住(#4472 次要发现 1)。ratchet 只对 相对 baseline 的**新增**分歧开火,所以失败必定是一次有意的注册表改动。 - 声明由测试钉住。check-react-blocks-declaration-parity.test.ts 断言它能看见的两个 方向、caveat 确实被打印、以及那句"实现"声明不会回来——Prime Directive #10 可执行的 那一半。 它看得见的东西没变,也仍然值得留着:spec-only(palette 缺口,软信号)、registry-only (未文档化的扩展,被 ratchet)、missing(没注册/非 public)。看不见的只有一类:两边都 声明了,没人读。 渲染路径上的证据只能从渲染路径上取,那是 objectui 那侧。那里的 public-block-binding-reach.test.tsx 把每个声明了 objectName 的 public block 挂在一个 会记录调用的 dataSource 下,断言绑定确实到达了数据层;首跑就把五个绑上的和三个没绑上 的分开,并暴露两个同形真缺陷(objectui#3144)——这恰好证明这类证据在 spec 侧从来取不到。 ADR-0082 带 addendum;2026-06 那份 audit 顶上加了更正横幅,指出那句让整件事看起来安全的 假设("the component reads its full config from the spec schema at render")是预期, 从未被测量。 Closes #4472 Claude-Session: https://claude.ai/code/session_01S3cP1eY1novcNhQEDBrSZD Co-authored-by: Claude <noreply@anthropic.com>
…nert condition (#4414) (#4493) Two follow-ups to #4440, both about metadata that reads like a guard and is not one. `flow-branch-label-unmatched` and `flow-default-edge-with-condition` now FAIL the build. The bar is restated at the top of the file, because the old one — "a guaranteed runtime failure" — no longer described the set: it is now **no reading of the author's metadata does what it says, deterministically, on every run**. A branch label no out-edge carries cannot route; an edge that is both `isDefault` and conditional always lets the condition win, so the marker routes nothing. Neither FAILS; both are wrong every time and silently. The other two stay advisory, and the policy now says why. A decision with one guarded and one unconditional out-edge is usually a guard that does not guard, but it is also a legal "maybe notify, always continue" fan-out, and two default edges can genuinely mean "when nothing matched, do both". The bar is provability, not severity of consequence — failing a customer's build on a shape we cannot prove wrong is the worse trade. No wiring change: the rule is already `tier: 'gating'` across all three commands (#4409). `flow-inert-node-condition` is new. `config.condition` is the trigger gate on a `start` node and is read by no other node type — the engine parse-validates it everywhere and then ignores it, so on a `decision`, where the name makes it read as the branch predicate, it is a guard that gates nothing. Two of the three bundled apps had one: app-todo's `check_recurring` and app-showcase's `needs_exec`, each a third copy of a predicate its out-edges were already enforcing. The showcase even carried a comment saying the node condition "is not evaluated by the engine" and kept it anyway — the residue this rule exists to stop accumulating. Both are now plain exclusive gateways. Advisory: the surrounding edges usually still route correctly, so it is dead weight rather than a provable misroute. The node-type list is a closed set of builtins whose executors were actually read, not "everything but `start`" — ADR-0018 keeps `node.type` open and a plugin executor may legitimately declare and read its own `config.condition`. Claude-Session: https://claude.ai/code/session_01Q8as8yR67v41xEdomiTba9 Co-authored-by: Claude <noreply@anthropic.com>
…#4454) (#4492) * feat(automation,metadata-protocol): one flow canonicalization policy, reusable off the load seam (#4454) `os migrate meta --stored` (#4327) covers every metadata type except `flow`, because flow-node conversions carry ADR-0078's open-namespace conflict guard and that needs the automation engine's live executor registry. This is the half both possible hosts need. `AutomationEngine.canonicalizeStoredFlow` is now the single policy, and `registerFlow` calls it — so the load seam and any stored-row migration can never disagree about what "canonical" means. It returns two shapes from one pass: - `parsed` for EXECUTION — FlowSchema.parse + the #4347 region pass, schema defaults materialized. What registerFlow runs. - `storable` for PERSISTENCE — conversions plus the `{dialect, source}` envelopes the schema derives for edge conditions, and deliberately nothing else. Excluding schema defaults is the load-bearing decision, and it was measured rather than assumed. Driving a pre-17 flow through all three steps shows parse + normalize REMOVE nothing (FlowSchema is strict since #4001, so an unknown key throws instead of being dropped — the graftNormalizedOperators precedent does not transfer) and ADD only defaults: `version`, `runAs`, per-edge `type` / `isDefault`. Persisting a default the author never wrote would pin every migrated row to today's value while untouched rows follow tomorrow's — two populations with different behaviour, which is the drift this pass exists to remove. A migration must not become a source of it. `migrateStoredMetadata` gains an optional `canonicalizeFlow` hook. Without it, flow rows keep reporting `skipped` with the reason. With it: conversions are applied and reported per row, a refused rename (the guard firing over a live third-party node type) fails the row loudly naming the token, and a flow that cannot canonicalize at all is reported rather than persisted as a guess. One subtlety the tests pin: the condition envelope is a schema transform, not a conversion, so it emits NO notice while still changing the body. Reading notices alone — correct for every other type — would call such a row canonical and leave it re-deriving on every boot. Both passes are copy-on-write, so identity (`storable === body`) is the exact test for flows. No host is wired yet: booting the automation plugin in the CLI would arm triggers and schedulers (registerFlow activates them), so that needs either an inert plugin mode or the admin route. Tracked in #4454. Refs #4327, #3903, #4001, #4347, ADR-0078, ADR-0087 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WoZPKPDqJ7WB7z84xk9y3f * feat(automation,migrate): os migrate meta --stored covers flow rows (#4454) The stored-metadata pass (#4327) skipped `flow` — the one type where the most stored dialect actually lives, since the graduated conversions `flow-node-crud-filter-alias`, `-object-alias`, `-notify-config-aliases` and `-script-config-aliases` are all flow-node entries. Flow-node conversions carry ADR-0078's open-namespace conflict guard, which must consult the LIVE executor registry to tell a rename from a clobber, and the metadata layer cannot obtain one. This wires the engine in without wiring in a server. `AutomationServicePluginOptions.armRuntime` (default true — every existing host is unaffected). With `false` the plugin brings up the engine and the COMPLETE node registry — built-ins plus whatever `automation:ready` contributes, because a partial registry would make the guard read a live custom node type as unowned and rewrite over it — then stops before anything is armed: - no flow registered (registerFlow calls activateFlowTrigger, so record triggers and scheduled jobs would go live); - no kernel:ready / metadata:reloaded re-sync (skipping only the boot pull would arm them a moment later); - no declarative connector materialized (an MCP provider spawns a process); - no suspended wait-timer re-armed (it RESUMES paused runs — a migration that silently continues someone's approval is indefensible). `os migrate meta --stored` boots it in that mode and passes `canonicalizeStoredFlow` as the protocol's `canonicalizeFlow` hook. A migration process must not become a second server. `IAutomationService.canonicalizeStoredFlow` is declared on the contract rather than only on the implementation, because the CLI consumes it through the `automation` slot — which is exactly what the slot-lookup lint rule asks for instead of erasing the lookup to `any`. Verified against a real database, not only unit tests: a legacy flow row (`config.filters`) seeded into examples/app-todo migrates preview(exit 1) → apply → re-run(exit 0), the row comes back with `config.filter`, a `sys_metadata_history` entry sourced `migrate-stored`, and — the load-bearing part — NO schema defaults written (`version`, `runAs`, per-edge `type` are absent), so the row is not frozen on today's default values. Boot logs confirm 17 executors registered and then "inert mode … no trigger or schedule armed". Refs #4327, #3903, #4001, #4347, ADR-0078, ADR-0087 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WoZPKPDqJ7WB7z84xk9y3f --------- Co-authored-by: Claude <noreply@anthropic.com>
) (#4500) BREAKING CHANGE: @objectstack/spec/integration no longer exports the six per-provider connector schemas — DatabaseConnectorSchema, FileStorageConnectorSchema, GitHubConnectorSchema, MessageQueueConnectorSchema, SaasConnectorSchema, VercelConnectorSchema — nor their ~100 sub-schema/type/ example exports (2,672 lines). The six generated reference pages go with them. #4480 started as one dead key (DatabaseConnector.readReplicaConfig, found while removing datasource.readReplicas in #4468) and scoped out to the whole cluster: zero consumers for all six schemas. Nothing in the monorepo imported any of them — not the automation engine (engine.registerConnector validates against ConnectorSchema from connector.zod.ts, engine.ts:1379), not the `connectors:` stack collection (DeclarativeConnectorEntrySchema), not even their own module's live half. They were the losing side of a decided architecture fight, left standing. ADR-0023 rejected hand-modelling each external system's shape inside the spec; ADR-0097's connector protocol does the opposite — provider shapes come from the provider itself (connector-openapi materializes instances from an OpenAPI document, connector-mcp from an MCP server). The templates hardcoded Postgres/S3/GitHub/RabbitMQ/Vercel shapes nothing would ever read, and were semantically wrong where they overlapped the live platform: DatabaseConnectorSchema modelled "tables to sync", CDC, and readReplicaConfig — read-replica routing declared a SECOND time, down to a `weight` field for a load balancer that does not exist. External-database access is datasource federation (ADR-0015), which is live and is not a connector. The removal kit: - integration/index.ts: six export lines removed; module header now records the decision so the next reader finds the ADR trail, not a gap - json-schema.manifest.json: 47 entries deleted deliberately (the gen:schema ratchet's documented retirement path) - authorable-surface.json: 365 lines deleted deliberately (gate (a)'s strict- removal trip wire, whole-cluster edition) - api-surface.json regenerated from the rebuilt dist - docs: six connector-* reference pages deleted, plus three sibling pages (message-queue/object-storage/tenant under references/integration) whose entire content documented deleted-file exports; section meta + index regenerated; hand-written tables in references/index.mdx and getting-started/quick-reference.mdx rewritten around the one live protocol - PROTOCOL_MAP.md: six rows collapsed into the connector.zod.ts row (the protocol-map link test is what caught this surface) - v17 release notes: dead-clusters table row - changeset: major, with the no-migration rationale (these schemas validated no stored metadata, so there is no D2 conversion to register — nothing to rewrite) No runtime behaviour changes: the ADR-0097 path — ConnectorSchema, DeclarativeConnectorEntrySchema, the provider contract, connector-descriptor, connector auth — is untouched, which the service-automation suite (609 tests) confirms. Follow-up filed: #4499 — automation/trigger-registry.zod.ts carries a THIRD declaration of the same business need (its own ConnectorSchema cluster, ~440 lines, zero runtime consumers; the engine imports integration's schema). Kept out of this PR: that file mixes the dead cluster with trigger-registry exports that need individual verdicts. Closes #4480 Claude-Session: https://claude.ai/code/session_01WsgTqRF58HsQYKLsrZ5pQY Co-authored-by: Claude <noreply@anthropic.com>
New advisory rule `action-no-placement`: an action that declares no `locations` and that no list view places by name renders on NO surface — it parses, publishes, shows up in Setup, and no user can ever click it. ADR-0078 names this shape in its opening paragraph and Phase 3 asks for exactly this rule; the shared completeness predicate it envisioned was never built, so this lands standalone, one verified shape at a time. What made it verifiable now: objectui#3142 collapsed four disagreeing renderers onto one placement predicate. Before that, action:bar and the record header rendered an UNdeclared action anyway, so the shape only looked inert on paper. As of objectui 17.1 it is measurably inert. Two deliberate non-findings: - `locations: []` — the documented headless action (callable over REST / MCP / AI, no UI surface). ADR-0110 D3 refuses an undeclared handler, so this is the only legal way to expose one; flagging it would fight that ADR. The rule distinguishes "nowhere, deliberately" (`[]`) from an unstated placement (key absent) and reports only the latter. - Actions a view places by NAME — bulkActions, bulkActionDefs (including `execution: 'aggregate'` defs, whose whole point is an action with no single-record home) and rowActions, across all three list-view tiers: views[i].list, views[i].listViews.<key>, and the object-embedded objects[i].listViews.<key>. Advisory, never fatal: a view in another installed package may be the one placing the action — the same reason validateSemanticRoles and lintLivenessProperties warn rather than gate. Verified zero findings against app-showcase, app-crm and app-todo. Also in this change: - metadata-protocol's action form schema stops declaring `shortcut` / `bulkEnabled`. Both are spec-17 retiredKey() tombstones, and this schema is what the Studio designer renders its fallback form from — advertising them handed authors two inputs that could only ever produce an unsaveable draft (objectui#3145 removed the matching dedicated controls). - content/docs/ui/actions.mdx said naming an action in a widget never bypasses location filtering. The selection bar IS the exception, which the showcase's own task.view.ts comment already stated — the two now agree. Claude-Session: https://claude.ai/code/session_01S9aiswZBzoVYsyLKRuGByE Co-authored-by: Claude <noreply@anthropic.com>
* wip: analytics record-level scoping (#4467) + measure field validation (#4437) Two of the three v17 verification defects on the analytics query path. Both reproduced live on a showcase dev server before the change and re-verified after; regression tests still to be added (hence wip). #4467 — /analytics/query ignored record-level scoping `ISecurityService.getReadFilter` documents itself as "the same filter the engine middleware AND-s into every find", exposed for paths that bypass the middleware (the analytics raw-SQL path has no other source of scope). That middleware chain is TWO siblings: plugin-security's RLS injection and plugin-sharing's owner/share visibility filter. Only the RLS half was ever computed, so the analytics path ran with no owner predicate at all. Live repro (showcase, `showcase_private_note` sharingModel:'private', admin owns 5, member holds 2 shares and no viewAllRecords): GET /data/showcase_private_note member -> total 2 correct POST /analytics/query {measures:[count]} member -> count 5 LEAK ... + dimensions:["title"] member -> all 5 titles getReadFilter now resolves plugin-sharing's buildReadFilter through the late-bound `sharing` service and AND-composes it with the RLS filter, and computes the ADR-0057 D1 `__readScope` depth the middleware normally stashes on the context (no middleware runs on this path). Resolved for every non-system caller ahead of the RLS branches — none of the RLS stand-downs is a reason to drop a sibling middleware's predicate — and a resolution failure denies rather than emitting unscoped SQL. #4437 — a measure naming a missing field 500'd with SQLITE_ERROR `inferMeasure('ghost_sum')` built `SUM(ghost)` with no way to know the field exists; the driver threw `no such column` and the caller got `500 {"code":"SQLITE_ERROR","message":"Internal server error"}` — a driver error class on the wire for a plain typo (ADR-0112). The DATA route has refused the same mistake with a 400 naming the field since #4315/#4254. `ensureCube` now validates each measure's resolved source field against the backing object's field names before any SQL is built, and rejects with the same envelope the data route uses (400 INVALID_FIELD + field/object/param). Gated the same way as the #3867 inference gate: only for a cube whose `sql` is a bare object name, only when the new `getObjectFieldNames` probe answers, and only for measures whose source is a bare column (count(*) and dotted cross-object references pass through). Validation runs before the cube is registered so a rejected query leaves no trace in the registry. Refs #4467, #4437 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * test: pin the analytics scoping + measure-field gates (#4467, #4437) Regression cases for the two fixes in the previous commit, plus a polish to the #4437 rejection message. #4467 — `security-plugin.test.ts` gains an OWD/sharing block under the existing `getReadFilter service` describe: AND-composition with the RLS filter, the sharing predicate surviving alone when RLS contributes nothing, the ADR-0057 D1 `__readScope` depth being passed (no middleware runs on this path to stash it), fail-closed on a sharing-resolution throw, the isSystem bypass, and a deployment without plugin-sharing being unaffected. The harness gains an optional `sharing` service double. #4437 — a new `measure-source-field-gate.test.ts` covering the 400 envelope and its `field`/`object`/`param`/`measure` members, the dotted `total.sum` spelling, registry non-poisoning, every legitimate measure spelling still running, an authored cube whose declared measure lost its field, and the three stand-downs (no probe, an object the probe cannot describe, and a cube whose `sql` is an expression rather than an object name). A dotted cross-object measure is asserted to reach the STRATEGY — the layer that owns that decision — rather than being reported as a missing column here. Polish: the rejection listed the caller's own typo as a valid alternative on the auto-inference path, because `cube.measures` there was inferred from the very query being rejected. The suggestion list now excludes measures that failed the check, and names the object's known fields. Refs #4467, #4437 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * chore: add changeset for the analytics scoping + measure-field fixes (#4467, #4437) Both packages are publishable and both changes are observable on a public surface, so this is a real changeset rather than an empty one. Levelled `minor` on both counts. #4467 narrows a public read surface — analytics results a principal could previously read they now cannot, so counts drop and `dimensions` groupings lose rows for non-superuser callers on owner-private objects. #4437 changes the response envelope for a caller-shaped mistake (500 SQLITE_ERROR → 400 INVALID_FIELD), which any caller branching on `error.code` will observe. Neither changes an API signature: `ISecurityService.getReadFilter`'s declaration is untouched, and the implementation merely started honouring the contract it already documented. Refs #4467, #4437 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD --------- Co-authored-by: Claude <noreply@anthropic.com>
* wip: fix sharing-rule withdrawal (#4433) and DELETE 500 (#4434) An over-granting sharing rule had no withdrawal path on the API surface: deactivating it never withdrew its materialized grants, and the DELETE route answered 500 for both address forms. RC-exit blockers, since v17 advertises "switching a rule off actually withdraws access". #4434 — deleteRule purged sys_record_share with a predicate-shaped engine.delete carrying neither a scalar id nor multi:true, the one shape the engine's dispatch refuses. It threw before ever reaching the rule row, so every DELETE /sharing/rules/:idOrName 500'd. Routed through purgeRuleGrants instead of adding multi:true, so a rule's grants retire exactly one way — SharingService.revoke, as every other withdrawal path already does (AGENTS.md PD #5). #4433 — three independent gaps, one per path the issue walked: - deactivation: the rule-write trigger skipped reconcile on every isSystem write. defineRule (the only implementation behind POST /sharing/rules) writes with SYSTEM_CTX unconditionally, so the skip caught 100% of REST authoring. Now gated on boot phase, the question the skip actually meant to ask. - record touch: evaluateAllForRecord listed activeOnly rules, so a deactivated rule was absent from the loop and its grants were never examined. Lists every rule; inactive ones desire nothing and take reconcileForRecord's existing revoke branch. - boot: the backfill was handed activeOnly rules, making it structurally incapable of revoking. Walks every rule, plus a new sweepOrphanedRuleGrants for grants whose rule row is gone entirely (unreachable by rule iteration). Test fakes: makeEngine().delete now mirrors the real engine's dispatch guard. The looser fake is why #4434 shipped green — the pre-existing "deleteRule drops rule + all its grants" test passed against a delete the running server always rejected. Progress: service + plugin fixes done, regression tests added for both issues. Remaining: rule-rebind.test.ts still pins the old isSystem skip and needs updating; full gates + live-server verification pending. Refs #4433, #4434 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * test(sharing): pin the boot-phase reconcile contract (#4433) rule-rebind.test.ts asserted the defect: 'skips system-context writes' passed a mocked session the real REST path never sends, then asserted the reconcile did NOT happen. Replaced with the corrected contract — deferral is about WHEN a write happens (before the kernel:bootstrapped backfill), not WHO made it — plus cases for both session kinds after boot and the seeding deferral during it. pnpm --filter @objectstack/plugin-sharing test: 243 passed (11 files). Refs #4433 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * docs(sharing): document the withdrawal lifecycle + add changeset Live verification on a persistent SQLite datastore confirmed all three withdrawal moments and both DELETE address forms, so the guarantee is now worth stating: an over-granting sharing rule is always recoverable from the API surface. The CI docs-drift advisory flagged six hand-written pages against this change. None asserted anything false — the withdrawal timing was simply undocumented, which is how the behaviour drifted unnoticed in the first place. Added the missing section to permissions/sharing-rules.mdx (the three reconcile moments, delete-by-id-or-name, the boot sweep for grants whose rule row is gone) and sharpened permissions-matrix.mdx, which said rules re-evaluate on record insert/update and stopped there — true but partial now that rule writes and boot also reconcile. The other four flagged pages only list the plugin or its layer and needed no change. content/docs/references/ is generated and was not touched. Changeset: minor on @objectstack/plugin-sharing. Not patch — a `source: 'rule'` grant whose rule is inactive or gone now disappears on upgrade, and DELETE starts succeeding where it used to 500, so callers that treated that 500 as "unsupported" will now really delete. Refs #4433, #4434 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD --------- Co-authored-by: Claude <noreply@anthropic.com>
…4497) * test(metadata-core): make the ReDoS guard load-insensitive (#4485) The ReDoS assertion in protocol-handshake.test.ts bounded the pathological scan with an absolute 50ms wall clock. Under the full-repo run (~130 parallel turbo tasks) that ceiling measures machine load rather than the parser: it exceeded 50ms on a healthy tree and reddened PRs that never touched this package, leaving the diagnosis cost to whoever happened to be running. The underlying guard is real and stays (CodeQL 837/838). What changes is how it is measured. The three `toBeNull()` behavioural assertions -- the actual contract, that adversarial input is unrecognized rather than falsely rejected -- are kept and now stand on their own. The wall-clock proxy is replaced by a scaling check: the same adversarial shapes at 1x and 8x length, asserting the parse stays linear in the input. Load largely cancels out of a ratio, which is what makes the criterion load-insensitive. Measured: healthy parsing tracks the input at 8.3-8.5x, stable across runs. The 40x ceiling keeps ~5x headroom while still catching a merely quadratic regression (~64x), let alone an exponential one, which would not finish. Two measurement details are load-hardening, both established empirically: timings are taken back-to-back within one iteration and reduced by minimum *ratio* rather than minimising each timing independently (a scheduler steal landing in only one window skewed the latter, observed reddening at 3x CPU oversubscription); and the JIT is warmed so the baseline is not inflated. Note the pathological-to-benign ratio suggested on the issue does not work here: a benign 16-char range parses ~300x faster than a 100k-char one purely because it is 100k characters shorter, so it would fail on a healthy machine. Verification: 5/5 consecutive clean runs, plus 8/8 under 3x CPU oversubscription (the shape that reproduced the original failure). Fixes #4485 * docs: date the v17 query-surface removals to 17, not 18 (#4476) Seventeen passages dated v17 removals to `@objectstack/spec` 18. They ship in 17 -- this train. The number is the actionable half of a removal notice: a reader on 16 asking whether upgrading to 17 breaks their cursor-paginated loop was told the removal is a major away, so they plan for it later and the upgrade breaks. Evidence that 17 is correct: `spec-changes.json` carries `toMajor: 17` for data.query.{cursor,distinct,joins,windowFunctions} and stack.api.requireAuth; this tree is 17.0.0-rc.1 with `PROTOCOL_VERSION = '17.0.0'`; and the keys are already `[RETIRED]` in `authorable-surface.json`. A removal cannot be retired in a 17 build and also ship in 18. #4476 fingerprinted nine locations. Grepping the bare pattern -- which the issue itself recommended over working the list file-by-file -- found eight more: - The nine listed: query-syntax.mdx (4), queries.mdx (4), troubleshooting.mdx. - query-syntax.mdx:98, the #4286 sweep summary paragraph, same error. - skills/objectstack-query/ (5): SKILL.md and the aggregation/pagination rules. These are agent-facing and the highest-leverage of the set -- an agent authoring a query reads them as ground truth. Body prose only, so the frontmatter-derived listings that build-skill-docs.ts generates are unchanged. - implementation-status.mdx (2): the same error shape on a different change, `api.requireAuth` (#3963), which spec-changes.json also puts at toMajor 17. Also records the fingerprint in the sweep run log, as #4476 asks. Runs 1-2 matched on surface names and so read past passages that named the right surface and the wrong release; the new row tells the next run to check the number, with spec-changes.json `toMajor` as the arbiter. Fixes #4476 * docs: restore the trailing options arg on IDataEngine reads (#4486) The `IDataEngine` block in data-engine.mdx wrote all four read methods with two parameters, dropping the trailing `options?: BaseEngineOptions` that the real contract gives each of them (packages/spec/src/contracts/data-engine.ts :70/85/89/90). The write methods in the same block each carried their own `options`, so the block taught exactly the wrong model -- "writes take options, reads do not" -- and that is the misconception #4251 existed to fix. The parameter is not incidental: the same `{ context }` object is correct as insert's 3rd argument but was SILENTLY DROPPED as find's, so an intended `isSystem` bypass vanished and control-plane reads came back empty once org-scoping hooks landed. Anyone -- human or agent -- writing code from this block was being led back to the pre-#4251 shape, against a failure mode that raises no error. Adds `BaseEngineOptions` to the block's import list (the contract imports it from the same module), and a callout recording the precedence the contract states: `query.context` remains supported, and when both are given `options.context` wins. `content/docs/kernel/contracts/` is hand-written -- only `content/docs/ references/` is generated -- so no generator run is involved. Fixes #4486 * docs(service-automation): rewrite the README against the real flow DSL (#4452) The README's flow sections described a DSL that has never existed. Every node type name was wrong (`record_create` vs `create_record`, `query` vs `get_record`), the interpolation dialect was Salesforce's `{!…}` which the platform does not parse, and branching/looping/error handling were written as nested `steps` arrays -- a shape the schema has no key for. Nothing in it ran. Since #4414 + #4439 this stopped being merely useless: `conditions[].expression` is on the expression ledger, so the README's `'{!trigger.record.amount} > 10000'` is now REFUSED at `registerFlow()` / `objectstack validate`. An author copying it got a CEL error whose advice ("drop the braces") did not fix their node, because the node's entire shape was wrong too. Rewritten from the schemas and executors, not from the old README: - Flows are a DAG of flat `nodes` + `edges`. Branching is an edge, and a decision routes by matching its branch `label` to an out-edge `label` -- the #4414 trap, called out inline. - The record-change binding lives on the `start` node's config (`{ objectName, triggerType, condition }`), not at the flow top level. - CRUD config table: `objectName` (not `object`), `filter` as an OBJECT (not a `filters` array of triples), `outputVariable` (not `output`), no `recordId`. Notes that `update_record` has no `outputVariable` -- the executor reads none. - Both expression dialects stated with the rule that disambiguates them: every condition is bare CEL, braces are for values. Says plainly that `{!…}` is not a dialect here. - `loop` / `parallel` / `try_catch` given their real ADR-0031 region shape (`config.body`, `config.branches`, `config.try`/`catch`), and `wait` its node-level `waitEventConfig` block rather than the invented `duration` + `nextSteps`. - Flow `type` values corrected (`schedule`, not `scheduled`). Per the issue's preference, the per-node reference is NOT duplicated here: the README now points at content/docs/automation/flows.mdx, the maintained one. Keeping a second hand-written node catalog in a package README is the #4027/#3569 shape that produced this drift. `pnpm --filter @objectstack/spec check:generated`: all 8 artifacts up to date. Fixes #4452 * chore: add release-nothing changeset for the v17 verification docs/test fixes The Check Changeset gate requires every PR to add at least one `.changeset/*.md` relative to base. This branch touches only `.md`/`.mdx` prose and one `.test.ts` file -- verified against the diff, no package source, no public export, no protocol change -- so the empty-frontmatter form is the accurate declaration: it publishes nothing. * docs(service-automation): note that parallel/try_catch are built-in too (#4452) The Node Types section presented `FLOW_BUILTIN_NODE_TYPES` (i.e. the `FlowNodeAction` enum) as the built-in set, but the ADR-0031 structured constructs `parallel` and `try_catch` ship registered builtin executors without appearing in that enum -- which is exactly why `FlowNodeSchema.type` validates against the live action registry rather than a closed enum. Left as written, the list contradicted the Advanced Features section just below it. * docs(service-automation): fix run-on sentence in the expressions section (#4452) --------- Co-authored-by: Claude <noreply@anthropic.com>
…4503) BREAKING CHANGE: @objectstack/spec/automation no longer exports the third declaration of the connector vocabulary — ConnectorSchema, ConnectorInstanceSchema, ConnectorOperationSchema, ConnectorTriggerSchema, ConnectorCategorySchema, the Authentication*/OAuth2Config/Operation* family, their inferred types, and the Connector.apiKey()/.oauth2() factory helpers. All 630 lines of automation/trigger-registry.zod.ts go, plus its test and generated reference page. Despite the filename the file contained no trigger registry: every export was connector vocabulary, self-contained and read by nothing. The automation engine registers and validates connectors against ConnectorSchema from integration/connector.zod.ts (ADR-0097) and never imported this one; the stack `connectors:` collection parses DeclarativeConnectorEntrySchema; outside the spec package the only references in the monorepo were the two doc generators that published it. A full dependent typecheck (119 tasks) passes with the file gone — the compiler confirms the zero-consumer verdict. This closes the connector triple-declaration (Prime Directive #12): the ADR-0097 contract is the one spelling, the six per-provider templates fell in #4480, this was the last copy. integration/connector.zod.ts's header loses its "When to use Integration Connector vs. Trigger Registry?" section with it — guidance that steered lightweight cases to a dead file with the platform's authority, the same defect class as the capabilities.readOnly prescription corrected in #4487. The removal kit, same shape as #4480: - automation/index.ts barrel line removed with a note recording the decision - authorable-surface.json: 69 keys deleted deliberately (gate (a)'s strict- removal trip wire); json-schema.manifest.json: 11 entries - build-docs page list and build-skill-references source list pruned; reference pages, skill references, api-surface regenerated (the references/automation/connector.mdx page survives with only the live DataSyncConfig on it) - strictness ledger: row dropped per the checker's contract, prose note keeps the audit trail (the old row's "descriptors are code-registered; bindings authored" was optimistic twice over); automation section total 99 → 88 - PROTOCOL_MAP row, quick-reference row, v17 dead-clusters row - major changeset with the name-collision warning: the live module also exports ConnectorTriggerSchema/Connector with different shapes, so an import-path find-and-replace is not a migration No D2 conversion: none of this was storable stack metadata, so there is no source for `os migrate meta` to rewrite. Closes #4499 Claude-Session: https://claude.ai/code/session_01WsgTqRF58HsQYKLsrZ5pQY Co-authored-by: Claude <noreply@anthropic.com>
…4483) (#4496) * fix(spec): the $search auto field set's lead ORDERS the set, it must not admit one (#4483) `autoDefaultFields` filtered every field through three exclusions (`SEARCH_AUTO_EXCLUDED_FIELDS`, `hidden`, unsearchable type) and then prepended the display/name/title field on an EXISTENCE check alone — so the exclusions did not hold for whichever field happened to lead, and the module's own "system / audit / heavy fields never auto-included" invariant was false. Not a contrived shape: ADR-0079's `provisionPrimary(schema, { synthesize: false })` designates `nameField` at registration, and on a table whose only textual column IS the primary key (system tables, junction tables, append-only logs) it designates `id`. `$search` then expanded to `{ id: { $contains: term } }` — a substring scan over the primary key, returning a narrow and semantically wrong row set. It loosened a second layer too: `resolveSearchFieldResolution` is also the #4254 REST ingress gate's arbiter for "would the engine actually scan this field", so with `id` in `allowed` a `$searchFields=id` override was ACCEPTED rather than refused. The lead's job is to put the primary title FIRST, never to admit it, so it is now chosen from the already-filtered set. An excluded / hidden / unsearchable display field simply does not lead and the set is unchanged; an eligible one still leads, so the ordering intent is intact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * wip(drivers): give the uncompilable-filter refusal an ADR-0112 code and drop the driver prefix (#4436) IN PROGRESS — code change complete, regression test not yet written and the real-boot curl repro not yet run. A filter carrying an operator the driver cannot compile is already REFUSED rather than silently matched (#4209/#4029), but the refusal had no wire identity: the thrown `Error` carried no `code`, so `mapDataError`'s default branch served `{"error": "[sql-driver] Unsupported filter operator …"}` — no `error.code` at all, breaking the ADR-0112 contract every sibling rejection on the same route already honours (`INVALID_FIELD`, `INVALID_FILTER`, `RECORD_NOT_FOUND`), and leaking the `[sql-driver]` internal prefix that the #3867 sanitiser exists to keep off the wire. Both drivers now throw through a local `unsupportedFilterError` that stamps `code = StandardErrorCode.enum.INVALID_FILTER` (the same catalogued code `metadata-protocol` emits when a filter fails to parse upstream — one condition, one wire code however the caller reached it) and `status = 400`, which also puts the rejection on `isExpectedQueryRejection` so a client mistake stops being logged as an unhandled server error. The internal prefix is gone from the message; the actionable operator/field/vocabulary detail stays. Applied to every filter-COMPILATION refusal in both backends, not just the one branch the issue names — they are the same envelope defect on adjacent lines, and #3948 made the two drivers agree that an uncompilable filter is a refusal, so their refusal envelopes have to agree too. TODO: regression tests (driver-sql, driver-memory, REST envelope) + boot repro. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * fix(drivers): the uncompilable-filter refusal speaks INVALID_FILTER, without the driver prefix (#4436) Completes the WIP commit: adds the remaining sql-driver throw sites and the regression tests for both backends. #4209/#4029/#3948 settled the POSTURE — a filter carrying an operator the driver cannot compile is refused instead of silently matching every row. What was missing is the refusal's IDENTITY on the wire. The driver threw a bare `Error`, so `mapDataError` fell through to its default branch and served a body whose only key was `error`: GET /api/v1/data/showcase_task?filter={"title":{"$bogusop":"x"}} → 400 {"error":"[sql-driver] Unsupported filter operator \"$bogusop\" …"} Two contract breaks in one body — no `error.code` at all on a route whose sibling rejections all speak the ADR-0112 catalogue, and the driver-internal `[sql-driver]` prefix on the wire, which is what the #3867 sanitiser exists to stop. Fixed at the throw site (PD #12), not by teaching the REST layer to guess: both drivers now refuse through an `unsupportedFilterError` helper that stamps `code = StandardErrorCode.enum.INVALID_FILTER` — the constant, so a catalogue rename breaks the compile — and `status = 400`. `INVALID_FILTER` is the same code `metadata-protocol` already emits when a filter fails to parse upstream (`malformedFilterArrayError` / `unusableFilterError`): one condition, one wire code, however the caller reached it. The `status` also puts the rejection on `isExpectedQueryRejection`, so a client mistake stops being logged as an unhandled server error. Applied to every filter-COMPILATION refusal in both backends, not only the one branch the issue names: unsupported operator ($-object, legacy triple), unrecognised logical keyword, unrecognised element type, and a `between` / `$between` operand that is not a two-element array. They are the same envelope defect on adjacent lines, and #3948 made the two drivers agree that an uncompilable filter is a refusal — so their refusal envelopes have to agree too, or the cross-driver parity this repo relies on is false where it matters. Tests: new `sql-driver-filter-refusal-envelope.test.ts` (8) and `memory-filter-refusal-envelope.test.ts` (5) pin `code`, `status`, the absence of the internal prefix, and that the actionable operator/field/vocabulary detail survives. Full suites green: driver-sql 623 passed, driver-memory 286 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * fix(metadata-protocol): PATCH/DELETE of a nonexistent record answer RECORD_NOT_FOUND, not 200 (#4435) The READ path was already honest — `getData` on an unknown id answers `404 RECORD_NOT_FOUND`. Both single-record WRITE paths reported success for a record that does not exist: PATCH /data/showcase_task/definitely_not_a_row → 200 {"record":null} DELETE /data/showcase_task/definitely_not_a_row → 200 {"success":true} REST is a pass-through here (`res.json(await p.deleteData(...))`), so these are the protocol's answers and this is where they are fixed. What it cost: a client that PATCHed a concurrently deleted record was told the write landed, and had to null-check a SUCCESS payload to find out otherwise; `DELETE` said `success: true` for any string in the path, so a typo'd id, an already-deleted row and a real deletion were indistinguishable — including in bulk, where `deleteMany {"ids":["nonexistent_1"]}` answered `succeeded: 1`. It is the same silent-no-op shape the v17 train removed everywhere else this window (#4240/#4303/#4315, #4169, #4190), one level up. - `updateData` asks existence BEFORE the write, via the same `findOne` + caller context `getData` uses. Deliberately not a post-check on the returned row: the engine returns the post-write READBACK, which is also `null` when the row still exists but the write moved it out of the caller's row scope (reassigning `owner_id` away from yourself under an owner-scoped policy) — reading that as "not found" would 404 a write that succeeded. - `deleteData` and `deleteManyData` read the driver's own answer. The contract (`IDataDriver.delete` — "True if deleted, false if not found") already carried it; the code discarded it and pushed a literal `success: true`. Read as `=== false` on purpose: that is the contract's positive not-found value, while a driver returning the deleted row or an off-contract `undefined` gives no such signal, and inventing a 404 from a falsy return would break deletes against third-party drivers instead of reporting honestly. `success` on the 200 now means what it says. - The 404 envelope is extracted as `recordNotFoundError` so the read and the two write paths cannot drift apart again. Note on the issue's second half: the spec's `DeleteDataResponseSchema` declares `success`, not `deleted`, so the existing key is correct as-is and nothing renames. Tests: new `protocol.record-not-found.test.ts` (12) covers PATCH/DELETE/ deleteMany, the read/write agreement on the same id, delete-twice, mixed batches, the `=== false` reading, and that the existence probe is asked with the caller's context. Three `protocol.dropped-fields.test.ts` fixtures stubbed `findOne → null` while PATCHing — under the new contract that IS a 404, so they now describe an engine that has the row (they are about the strip channel, not about missing records). Suites green: metadata-protocol 169, rest + objectql unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * fix(runtime): a sandbox capability denial is a 500 crash, not a 400 rejection (#4431) The `action-crash-vs-rejection` contract (#3951) pins the table: a `SandboxError` WITH `innerMessage` is a body's deliberate throw → 400; a `SandboxError` with NO `innerMessage` — timeout, capability denial — is a crash → 500. Capability denials were answering 400: POST /api/v1/actions/showcase_task/rc1_crash_probe → 400 {"error":{"code":"VALIDATION_ERROR", "message":"SandboxError: capability 'api.read' not granted to action …"}} Why: the gate throws `SandboxError` synchronously INSIDE a QuickJS host function, which rejects the async IIFE inside the VM, so it returns through the `__error` side-channel — and the pump loop presumed everything arriving there was user code throwing on purpose, setting `innerMessage` unconditionally. The dispatcher's classifier then read that as a deliberate rejection. So every capability denial stayed invisible to gateway error rates, APM and alerting — exactly the blindness #3951 was written to close — and the client also received the `SandboxError: ` debug prefix that belongs only in server logs. `SandboxError`'s own jsdoc already said `innerMessage` is undefined for the sandbox's internal errors; that only held for denials detected OUTSIDE evaluation (a timeout, which takes the separate `budgetError` path). In-VM host-call denials — `ctx.api.*`, `ctx.log`, `ctx.crypto`, `ctx.api.transaction` — were misclassified. Fix: the sandbox's own faults now carry a marker THROUGH the VM. `hostErrorToVm` stamps `__objectstackSandboxFault` on any `SandboxError` it marshals, and the synchronous gates throw the VM handle it builds rather than a raw host error — quickjs-emscripten passes a thrown handle through verbatim while its `newError` path copies only `name`/`message`, which is precisely how the identity was lost. The reject handler reports the marker on the additive `__errorInfo` channel, and the pump loop, seeing it, rethrows with neither the `<kind> '<name>' threw:` wrapper (nothing threw — the sandbox refused) nor an `innerMessage`. The existing classifier then does the rest: name is `SandboxError`, no inner/code/fields ⇒ unexpected fault ⇒ `errorFromThrown(err, 500)`, and the message reaching the client is the capability text with the debug prefix stripped. A marker rather than a match on the flattened `SandboxError: …` text, because the flattening is user-reachable: a body that CATCHES the denial and throws its own business error must keep its 400, and that case is pinned. No ADR or contract was changed — this makes the runtime deliver the contract #3951 already specifies. Tests: new `sandbox/capability-denial-is-a-fault.test.ts` (7) covers all four in-VM gates, the absence of innerMessage/code/fields, the prefix, the caught-and-rethrown rejection, an ordinary deliberate throw, and that a record `ValidationError` crossing `ctx.api` keeps its `code`/`fields` (the marker must not turn every failed write into a 500). Verified failing on all four denial cases before the fix. Runtime suite green: 73 files / 1033 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * chore: add changeset for the v17 REST envelope defects (#4431, #4435, #4436, #4483) * fix(test): call syncSchema with its real (object, schema) signature (#4436) The #4436 refusal-envelope test passed a single merged object where the driver takes the object name as its own first argument, so the suite could not type-check. Matches the idiom in the sibling memory-driver tests. * fix(metadata-protocol): one probe per PATCH, and the existence gate is not an RLS gate (#4435) Follow-up to 959b838, fixing two defects the first cut introduced. Both were caught by CI (`Test Core` on @objectstack/objectql, `Dogfood Regression Gate 1/2`), and the second is the more serious of the two. ## 1. The existence probe duplicated OCC's read `updateData` called `assertVersionMatch` (which reads the row for its `updated_at`) and then `assertRecordExists` (which reads the same row again). Two round-trips per PATCH — a performance regression no gate reports — and the `protocol-data.test.ts` OCC cases said so directly ("expected to be called once, but got 2 times"). The two gates want the same row, so they now share one read: `probeRecord` fetches it, `assertVersionOf` became a PURE comparison over an already-read row, and `assertVersionMatch` survives only for `deleteData`, which needs no existence probe at all — the driver's own return reports whether a row matched, so a plain DELETE stays at zero extra reads and only an OCC token buys one. ## 2. The probe must ask EXISTENCE, not the caller's visibility The first cut probed with the CALLER's context, reasoning that it should match `getData`. That quietly turned the existence gate into an authorization gate: a row the caller cannot read comes back `null`, so the PATCH answers 404. Two things break. It moves an RLS decision out of the write policy. Whether an unreadable row may be written by id is the #1994 pre-image check's call, made inside `engine.update`. A probe in front of it adds a second, different rule — scope creep into the security model, out of a bug fix about missing records. And it disarms a revert-provable security proof. `@proof: rls-by-id-write` (`qa/dogfood/test/rls-fixture.dogfood.test.ts`, referenced by the `permission.rowLevelSecurity.using` liveness ledger entry) boots a fixture whose member can read nothing and has no write policy, and asserts the runner reports `rls-hole` — the RED half that proves the gate can go red at all. A caller-scoped probe 404s that PATCH and the proof goes green: if #1994 were ever reverted, this probe would MASK it. Accidentally hardening one path is not worth permanently blinding the gate that watches the whole class. So the probe runs as system and answers existence only. Authorization stays exactly where it was, and the sole behaviour added is the 404 the issue asked for: an id that names no row at all. Tests: `protocol-data.test.ts`'s OCC block now asserts the new contract — one probe on every PATCH (the existence probe, no OCC comparison without a token), still exactly one when OCC IS requested (the anti-duplication pin), 404 before any OCC verdict for a missing id, and DELETE without a token issuing no probe. Its fixtures now supply a row, because under this contract a PATCH of an absent record is correctly a 404 and those cases are about OCC. Two cases added to `protocol.record-not-found.test.ts` pin the system-context probe and that an unreadable-but-existing row still reaches the engine for RLS to decide. Green: objectql protocol-data 117, metadata-protocol 170, dogfood shard 1/2 38 files / 235 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD --------- Co-authored-by: Claude <noreply@anthropic.com>
…plicatePackage` (#4498) + an admin route for `--stored` (#4327) (#4504) * fix(metadata-protocol,rest): one seam for flow canonicalization — `duplicatePackage` (#4498) and an admin route for `--stored` (#4327) `duplicatePackage` promised "duplication never mints new rows in a pre-protocol dialect" and delivered it through `convertStoredItem`, which returns `flow` bodies untouched. `FlowNodeSchema.config` is an open `z.record`, so a pre-17 body sailed through `saveMetaItem`'s gate and landed verbatim in a brand-new row — making ADR-0087's "strictly shrinking" premise false for flows: run the migration, get a clean report, duplicate a package, and the population is back. The capability was already reachable. The protocol is constructed with an accessor for the kernel's service table (the same one `analytics` and `package` are read from) and the automation service registers under `automation`, so one private `resolveFlowCanonicalizer` serves every caller running next to a live engine: - `duplicatePackage` canonicalizes flow rows through it. A refused rename fails the item into the existing `failed[]` naming the token; a flow that cannot canonicalize fails the same way; with no engine reachable the source body is copied as-is. - `migrateStoredMetadata`'s `canonicalizeFlow` defaults to it, so the CLI stopped passing one — it booted the inert engine into the same kernel, so both routes reached the same instance. - `POST /meta/_migrate-stored` therefore needs no hook at all: gated on `manage_metadata`, preview unless `apply` is literally `true`, attributed to the caller, mounted on both the REST server and the runtime dispatcher and ledgered in both, plus `client.meta.migrateStored()`. Operators without shell access finally have the finish line the CLI form gives everyone else. Resolution is lazy per call: plugin init order does not guarantee `automation` is in the table when the protocol is assembled, and caching `undefined` from a too-early read would disable flow canonicalization for the process. An integration test boots the real CLI stack against a real database, seeds a pre-17 flow row, and asserts the rewrite lands with no hook threaded — plus the negative, that dropping the automation plugin reports `skipped` with the reason rather than counting the row done. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WoZPKPDqJ7WB7z84xk9y3f * docs(api): list `client.meta.migrateStored()` beside the other operator metadata calls The SDK reference's `client.meta` sample covers the governance/lifecycle family (publish, rollback, diff, diagnostics, audit); the new stored-row canonicalization call belongs in the same neighbourhood. The full behaviour — capability gate, preview posture, the CLI's `--stored` equivalent — stays in `deployment/cli.mdx` rather than being duplicated here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WoZPKPDqJ7WB7z84xk9y3f * fix(cli): state the ObjectQL slot's contract on the integration test's lookups The new stored-flow integration test reached the engine via `const ql: any = stack.kernel.getService('objectql')`, which the `slot-lookup` rule refuses — and correctly: `: any` switches off checking for every `ql.*` call below it while reading identically to code that has it. `SchemaStack.kernel` is untyped, so a type argument is a TS2347; the contract is stated on the RESULT instead, via one `engineOf()` helper the two tests share. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WoZPKPDqJ7WB7z84xk9y3f --------- Co-authored-by: Claude <noreply@anthropic.com>
…4505) * ci: Test Core 按包两路分片,verify-CLI 拆为独立并行 job CI 提速:PR 关键路径从 ~13 分钟降到 ~7 分钟。 - Test Core 改为 2 路矩阵,按包分片:scripts/partition-test-shards.mjs 以 测试文件数为权重做确定性 LPT 均衡(当前全量 573/572)。不用 dogfood 的 vitest --shard 透传,因为 vitest 4 对测试文件数少于分片数的包直接报错, 加 --passWithNoTests 后该包在所有分片上都不执行(静默丢覆盖);今天有 3 个单测试文件的包。required check 名称契约由新增的 test-gate 聚合 job 以裸 "Test Core" 上下文承载(#3622 教训,同 dogfood-gate)。 - `objectstack verify` CLI 步骤从 dogfood shard 1 尾部(串行 +4.5 分钟) 拆为并行的 dogfood-verify job,结果并入既有 Dogfood Regression Gate, 分支保护无需变更。 - temporal-conformance 的 Turbo 缓存回退键从 turbo-test-* 改指 turbo-build-core-*:test 命名空间分片化后,单个分片不再是其构建闭包 的超集,而 Build Core 构建全部包。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U8Ms7unkKxTrmNCX2r1dfs * ci: dogfood-verify 需构建示例 app 的完整依赖闭包 verify 步骤挂在 dogfood shard 1 时,workspace 已被测试步骤全量构建, --filter=@objectstack/cli 就够;独立成 job 后,打包的 objectstack.config 在运行时还要导入示例 app 自身的依赖(如 app-showcase 的 @objectstack/connector-mcp),其 dist 无人构建 → verify 时 ERR_MODULE_NOT_FOUND。改用 pkg... 闭包过滤同时构建 CLI 与两个示例 app 的依赖闭包,依赖变动时自维护。已本地实测 app-showcase verify 通过。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U8Ms7unkKxTrmNCX2r1dfs --------- Co-authored-by: Claude <noreply@anthropic.com>
…te name it references (#4457) (#4508) A selection-bar bulk action was `z.array(z.record(z.string(), z.any()))` — no shape at all. The real contract lived in objectui's `BulkActionDef` interface and in the executor that reads it, so every authoring mistake landed as a silent runtime downgrade: `opeartion` parsed and the executor hit `Unknown operation: undefined` per row; `excution: 'aggregate'` parsed and left the def per-record, so the endpoint written for ONE `_selectedIds` call got N calls instead. `ui/bulk-action.zod.ts` types it with the treatment `ActionParamSchema` got in #3746/#4001: a strict def whose unknown-key error names the offending key and the canonical spelling. It also refuses the combinations the executor never reads (`patch` outside an update, `execution` outside a custom, `params` on a delete, `batchSize` on an aggregate) and a hand-written `actionDef`, which the renderer attaches and which authored by hand would smuggle an action definition past the action registry. One shape that parsed before is now rejected: `operation: 'custom'` without `execution: 'aggregate'`. `resolveBulkActions` attaches a dispatcher for exactly one authored shape; every other custom def falls to `Promise.resolve()` per row — a button that reports success for every selected record and does nothing. The error names both legal forms. `params[]` stays `.passthrough()` (the renderer declares a widget-config catch-all), and the bulk-param/action-param spelling divergence is documented rather than converged — that needs a cross-repo change, and typing them as they are is what makes the divergence visible. Lint: `validate-action-name-refs` now covers `bulkActionDefs`, for the entries that are references rather than button ids (`execution: 'aggregate'`). The walk also reaches an object's own `listViews` for the first time, and the hint no longer tells a bulk-surface author to add a `locations` entry the selection bar does not read. Verified zero new findings against app-showcase / app-crm / app-todo. Claude-Session: https://claude.ai/code/session_01S9aiswZBzoVYsyLKRuGByE Co-authored-by: Claude <noreply@anthropic.com>
…rage complete (#4488) (#4510) Nine new ledgers (app, book, doc, email_template, job, mapping, seed, translation, validation; ~150 verdicts, all verifiedAt-stamped), emptying PENDING_GOVERNANCE: every registered metadata type is now governed. - check-liveness.mts: unwrap() sees through z.preprocess pipes (takes the OUT side when IN is a transform) — translation's registered schema was unwalkable before this. - CLI lint TYPE_COLLECTIONS registers the six new types carrying authorWarn entries, with pins in the test suite (21 tests). - liveness/README.md: the count-table method is decided and recorded (mirrors check-liveness --json byStatus); all rows regenerated, the stale webhook row rewritten to the post-#3489/#3494 state. Key findings, all recorded in ledger notes and tracked in #4509: - email_template: the ENTIRE authoring surface is disconnected from sendTemplate (false compliance on auth mail). - app: areas[].visible / areas[].requiredPermissions are fail-open dead gates; homePageId dead; selector includeAll deliberately ignored. - translation.validationMessages: read by nothing while #3778's own migration table steers authors into it. - job / validation: runtime-authoring doors disconnected from their execution points. Claude-Session: https://claude.ai/code/session_01WsgTqRF58HsQYKLsrZ5pQY Co-authored-by: Claude <noreply@anthropic.com>
…erent declaration, judged by symbol identity (#4446) (#4506) * feat(spec): ratchet cross-entry dual-source exports — same name, different declaration, judged by symbol identity (#4446) api-surface.json records every export per entry point, so a name appearing on two entries was VISIBLE — but nothing distinguished the two ways that happens, and only one of them is fine: a re-export (one declaration, two import paths) versus a DUAL-SOURCE (each entry resolving the shared name to its own declaration, so which type a consumer gets depends on nothing but the import path). The dual-source case is the #4411 trap: eleven names declared twice across ./kernel and ./system, where the copy that LOOKED canonical was the dead one — a pick by name compiled and failed later, at an edge value. New pure check `check:dual-source-exports`: - Judged by SYMBOL IDENTITY, not name: every export of all 16 public entries is resolved through its alias chain to the original symbol; a name whose entries resolve to >=2 distinct symbols is dual-source. Name-based counting would drown the signal — the real surface carries 148 legitimately re-exported names next to the 63 real findings. - Shrink-only baseline (dual-source-exports.baseline.json) records the 63 existing dual-sources — including the MetadataFormat ./shared≠./system enum divergence, the ./contracts third-shape interfaces, and two type-vs-const cases (ShareRecipientType, TransformType) the name-level scan could not even see. A NEW dual-source fails with the fix at the declaration (converge + re-export, or rename); a resolved one fails until its line is deleted. The baseline is hand-edited under review, deliberately NOT generated: a `gen:` that rewrites it would admit new dual-sources via "run the fix command" instead of via a maintainer decision. - Self-tests first (the check-exported-any pattern), pinning both edges: a fixture dual-source (incl. type-vs-const) must be flagged, a re-export must not, and count assertions keep a resolution failure from reading as clean. - Wired everywhere a new check must be: package.json, the check:generated reconciliation ledger (NO_GENERATOR — it would fail the run unclassified), lint.yml's TypeScript Type Check job after the build step, and the AGENTS.md pure-checks paragraph. Also fixes a fresh flake this work kept tripping over: the #4491 parity tests spawn a tsx subprocess that loads the whole spec surface (~4.5s alone, 5-7s under turbo's parallel load) against vitest's 5s default timeout — three consecutive full-suite runs failed a DIFFERENT test of that file each time, every one a timeout, while the file alone stayed green. The six spawning tests now carry an explicit 60s timeout: a timeout there should mean "the script hung", not "the runner was busy". Closes #4446. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M9uWvoEp9CoLzYjNExj9sL * chore(spec): shrink the dual-source baseline by the 8 pairs #4500 resolved First contact with reality, one merge in: #4500 removed the connector "template" cluster, deleting the ./integration copies of ConsumerConfig, DatabaseProvider, MessageQueueProvider and MultipartUploadConfig (type + Schema each). Those 8 names are no longer dual-source, and the gate's stale-entry leg refused to pass until their baseline lines were deleted — the shrink-only ratchet ratcheting down exactly as designed. 63 → 55. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M9uWvoEp9CoLzYjNExj9sL * chore(spec): shrink the dual-source baseline by the 3 pairs #4503 resolved Second catch in one afternoon, this time inside the merge queue: the queue built this branch against a main that had just landed #4503 (trigger-registry Connector cluster removal), which deleted the ./automation copies of Connector, ConnectorSchema and ConnectorTriggerSchema — and the gate refused the queue build until their baseline lines were gone. 55 → 52. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M9uWvoEp9CoLzYjNExj9sL --------- Co-authored-by: Claude <noreply@anthropic.com>
…ent` definitions (#4507) (#4512) `agent` is the only authorable type with no governed write path — ADR-0063 §2 closes `*.agent.ts` to third parties, so both `allowOrgOverride` and `allowRuntimeCreate` are false and nothing reaches `saveMetaItem`. Its rows are written by the shipping plugin at boot (`AIStudioPlugin.registerMeta` → `metadataService.register()` → `MetadataManager.register` → `DatabaseLoader.save`), which writes `sys_metadata` directly with a fresh checksum and appends no `sys_metadata_history` row. A shipped agent definition that changes between releases therefore leaves no metadata-side change log. That is a deliberate position, and it now sits beside the declaration rather than only in an issue: the two definitions live in version control (`@objectstack/service-ai-studio`, `cloud` repo), so git already holds the full reviewable history, and a second history in `sys_metadata` would be a WORSE record — it would capture only the boots where a given deployment saw the checksum move, so two deployments on one release would carry different "histories" of an identical, code-fixed definition. The note names the two consequences that read as bugs and are not: the `skipped` outcome `os migrate meta --stored` reports for `agent` is correct and permanent for this type, and Studio showing no History tab is the absence of anything to show. `migrateStoredMetadata`'s TSDoc — where the skip reason was what made this look like a gap — now points at it. It also states its own expiry: opening `agent` to tenant authoring removes the git fallback, so opening the type and giving it a real history path are the same piece of work. Comment-only; empty changeset. Claude-Session: https://claude.ai/code/session_01WoZPKPDqJ7WB7z84xk9y3f Co-authored-by: Claude <noreply@anthropic.com>
…4469, #4470) (#4502) * wip: rescue in-progress work before container restart loss (flows) * fix(automation): validate screen resume input against the node's declared fields (#4477) Changeset for the screen-resume field contract landed in the preceding commit (which the container restart forced out as a WIP rescue): `resume` now refuses a bag that violates the suspended screen's declared `fields` — a missing `required` value the caller WAS asked for, or a key the screen never declared — with the new `INVALID_SCREEN_INPUT` code, mapped to 400 by the automation domain route. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * fix(approvals): record an admin override of a staffed approver slate as an override (#4466) `sys_approval_action` had no override column, so an admin overriding a properly staffed approver slate wrote a row byte-for-byte identical to the designated approver approving normally. A reader of the timeline saw `approve` by the admin and could not tell which had happened; the bypassed approver's `409 INVALID_STATE` was the only trace, and only if they happened to try. The platform knows at decision time — it took the `isOverrideActor` branch to admit the call — so this was dropped information, not unavailable information. Adds `sys_approval_action.via_override` (optional boolean), set on exactly the actions admitted by that branch: `decideNode`'s approve/reject and `reassign`'s admin rescue. Surfaced on `ApprovalActionRow`, returned by `listActions`, and added to `highlightFields` + two grid views. `true` = admitted only by the override branch. `false` = checked, not an override (an admin who IS a slot holder approves normally). Absent = a row predating the column — "not recorded" is not the claim "not an override", so `rowFromAction` maps `null` to `undefined`. Additive and nullable: no migration needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * fix(automation): type screen-input issue codes as FieldErrorCode, exempt the file from ADR-0112 D1 `check:error-code-casing` flagged `ScreenInputIssue.code`'s `'required' | 'unknown_field'` literal union as a lowercase `error.code`. It is not one: these are FIELD-ADDRESSED validator codes (ADR-0114 D2, ADR-0112 D6) carried inside the refusal's message, while the refusal's own machine code is the SCREAMING `INVALID_SCREEN_INPUT` the engine returns. Typed as the shared `FieldErrorCode` for the reason `ActionParamIssue` gives — a screen field, an action param and a record column must not drift into three vocabularies for the same two conditions — and registered in the checker's EXEMPT_FILES with its reason, beside the `action-params.zod.ts` precedent it mirrors exactly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * fix(approvals,verify): find stranded terminal requests; stop the harness pinning memory (#4469, #4470) #4469 — `releaseDeadRunRequests` scans `status: 'pending'`, and the very step that zombifies a request is the one that takes it OUT of `pending`: breaking it removed it from the only sweeper's field of view. Its oracle could not have answered anyway — `getRun` reads the execution LOG, which returns null for a perfectly alive suspended run after a restart. Adds `ApprovalService.inspectStrandedRequests()`, a READ-ONLY inspection over `approved`/`rejected`/`returned` rows that uses BOTH oracles and reports only rows failing both: `hasSuspendedRun(runId) === false` (no live pause) AND `getRun(runId) == null` (no terminal history row either). A store that THROWS is skipped and counted as `undetermined`, never condemned — an outage means unknown. It never rewrites a decision that really happened; it reports which requests are stuck at which step and what the mirrored status field still reads, and rides the existing sweep clock so the finding surfaces without an operator going looking. #4470 — `packages/verify/src/harness.ts` pinned `suspendedRunStore: 'memory'`, making the DB-backed path structurally unreachable from every dogfood/e2e fixture. Engine-side persistence was unit-tested against a fake table and the approval chain e2e-tested wholly in memory, while the assembly between them was covered by nothing — the seam #4420 grew in. The harness now boots the plugin's own `'auto'` default (a fixture opts out explicitly), and gains `databaseFile` so two sequential boots over one file are a genuine cold start. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * wip: rescue in-progress work before container restart loss * test(dogfood): assert what the durable suspended-run row actually contains (#4470) CI showed the harness store change (`'memory'` → the plugin's own `'auto'`) regressed nothing: 38 dogfood files and 239 tests passed, and the single failure was this file's own cold-boot assertion. Investigating that failure found a real blocker, so the proof is rewritten around what can be established rather than around what would be convenient. A second `bootStack` over the same `databaseFile` reads a database whose tables exist but whose ROWS are gone — ordinary records do not survive it either, which is what identifies it as a harness/driver persistence gap rather than a defect in the suspended-run store. #4470's third bullet is therefore documented as a KNOWN GAP in the file header, not faked with a weakened assertion. What is asserted instead is the substance of the issue's first two bullets, and all of it is a fact rather than the absence of an error: - `sys_automation_run` exists and is queryable (the table #4420 was missing); - the pause lands as a real `paused` row, read back by id, carrying every field a rehydration needs — `node_id`, the `node_type` the resume gate keys on, a parseable `variables_json` holding the trigger input, `steps_json`, and the `screen_json` a resumed pause validates against; - resuming CONSUMES that row and leaves the `run_`-prefixed terminal history row in its place — the zombie shape #4420 produced; - the screen contract (#4477) is enforced against the persisted `screen_json`, and a refusal leaves the durable row intact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD --------- Co-authored-by: Claude <noreply@anthropic.com>
…the check that found two live 422s (#4001) (#4514) Three things, in the order they forced each other. 1. `strictObject` — closing a shape is one call. The #4001 wiring was four parts per schema plus a drift test: a hand-transcribed `const X_KEYS = [...]`, a `strictUnknownKeyError({ knownKeys: X_KEYS, … })` call, the `{ error }` argument, `.strict()`, and an "accepts every declared key" probe to catch the array drifting from the shape it describes. 34 key arrays and 16 probe files, with most of the authorable surface still ahead. The array was never necessary. `knownKeys` feeds one thing — the edit-distance fallback — and the shape object is at the call site. `strictObject` reads the keys from `shape`, which also retires the per-schema probe: a list read from the shape cannot disagree with it. `aliases` and `guidance` stay hand-written and stay OPTIONAL; they carry judgement rather than transcription, and treating curation as a precondition is part of why this ratchet moved slowly. 2. A sharper target than "the five authorable directories". That triage answers "is this authorable?" but not "is this PARSED?" — and after #4410 the second question decides whether a flip enforces anything at all. `BUILTIN_METADATA_TYPE_SCHEMAS` answers both: every entry is author-written and parsed on three paths (`defineStack()`, `/api/v1/meta/types/:type`, the Studio form). Ten had no strictness; `seed` and `doc` are the first two converted. Five of the ten live in `system/`, which the directory triage never covered — the two lenses miss different things, so the ledger now carries both. 3. The check, and the two live bugs it found immediately. `MetadataPlugin`'s loader stamps `_packageId` / `_provenance` on every registered type, so a strict schema that does not declare `MetadataProtectionFields` rejects its own loader's output — a hard 422 on the ADR-0094 overlay path. That defect had been found three times by hand (`permission`, `position`, then `seed`/`doc` here). Finding one thing three times is evidence the check is missing, so this adds it as an invariant over the registry. It found the fourth and fifth on its first run: `hook` and `datasource` had both gone strict in the #4001 data step WITHOUT the envelope, and were sitting on `main` in the hard-422 class. Three prior hand-searches for exactly this defect had walked past them. Both now declare it; no registered type rejects the envelope any more. The test asserts that case unconditionally — no exemption list — and tracks the quieter strip case (`field` only) separately, since each entry there becomes a rejection the day its schema closes. Also: the ledger gate caught its own blind spot again. `strictObject(` did not match its `z.object(` counting method, so the first conversion read as a site disappearing. Counting only `z.object(` would make "this directory got solved" and "this directory got deleted" produce the same number, so the method now counts both. Authoring impact on `seed` / `doc`: a key the schema never declared is rejected instead of silently discarded — it was already ignored, so no working behavior changes. Rejections name the surface, echo the key and suggest the closest declared one (`rows` → `records`, `body` → `content`), with tombstones for `path` / `slug` on `doc`. Published JSON Schema unchanged: output-mode conversion already emitted `additionalProperties: false` for these shapes. `validation` is the remaining registered type with the envelope gap — a `z.lazy()` discriminated union whose variants `.extend()` a shared base, so it needs per-variant conversion rather than one call. Tracked in the test's debt list and the ledger. Verified: spec 282 files / 7115 tests, `tsc --noEmit` clean, all 8 generated artifacts current, all 15 `check:*` gates green. Example-app seed definitions and doc frontmatter checked directly against the new shapes — only declared keys. Claude-Session: https://claude.ai/code/session_01WnqGjQFQMqd5k81LYV8SCY Co-authored-by: Claude <noreply@anthropic.com>
…4517) * docs(changeset): changesets for the stranded-request inspection and the harness store (#4469, #4470) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * docs(automation): document the screen resume contract and the override marker (#4477, #4466, #4469) flows.mdx — a new subsection on the durable-pause seam, beside the existing `$`-namespace rule it mirrors: a `screen` node's `config.fields` is a contract, `resume` enforces it server-side with 400 `INVALID_SCREEN_INPUT`, `visibleWhen` is evaluated against the submitted values so a hidden field's `required` never fires, the refusal precedes consuming the suspension, and the three shapes that declare no contract keep the pass-through. The pausing-node table's `screen` row now says the inputs are validated rather than merely posted. approvals.mdx — the admin-override callout now states the rule it actually implements ("the actor is an admin", not "the slate is unstaffed"), which is why `sys_approval_action.via_override` records which door the decision came through, including why an admin who IS an approver records `false` and why a legacy row's absent value reads as "not recorded". The dead-run callout gains the terminal request shape its pending-only scan cannot see, and why the new inspection reports rather than rewrites. content/docs/references/ is generated and untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * docs: point the "filed separately" references at #4518 Both the durable-suspend proof's KNOWN GAP note and the `@objectstack/verify` changeset told the reader the harness persistence gap was "filed separately" without naming it — a pointer to nothing, which is the shape of stale record this branch exists to avoid. It is #4518. Comment/changeset text only; no behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD --------- Co-authored-by: Claude <noreply@anthropic.com>
…f 25 types (#4001) (#4519) The invariant test added one change ago asserted two things about every registered metadata type: that it does not REJECT the ADR-0010 envelope its loader stamps (the hard-422 case), and that it does not silently lose it. The reject half worked — it found `hook` and `datasource` on its first run. The other half did not. It probed each schema with one generic body and asked whether `_packageId` survived. A type whose required fields that body did not supply failed for unrelated reasons, and the assertion returned early. TWENTY- FOUR of the twenty-five types took that early return. Only `field` was ever really checked, and the suite reported green. That is this campaign's own subject matter — a success signal covering an omission — reproduced inside the instrument built to detect it, one change after the ledger recorded the identical lesson about the strictness gate's non-recursive directory walk. A check that skips is indistinguishable from a check that passes. The declaration side is now STRUCTURAL: it walks the schema, unwrapping lazy/pipe/optional/default and expanding unions, and asks whether any resolved object shape declares the key. That needs no valid instance, so it cannot skip. Two guards keep it honest: - a type the walker cannot resolve is a hard FAILURE, not a pass. The walker going quiet is exactly when this test would otherwise stop covering something. - the debt list carries a reverse pin that fails when an entry is fixed, so the list cannot outlive the debt it tracks. What it found: 8 registered types do not declare the envelope, not 1 — `action`, `book`, `field`, `job`, `mapping`, `page`, `translation`, `validation`. Each loses protection metadata on every round-trip today and becomes a hard 422 the day its schema closes. `job` and `book` are closed here; 6 remain listed. Three occurrences now of one pattern, in three different instruments: the ledger gate's non-recursive walk, `strictObject(` not matching the site count, and this early return. Every one was a measuring tool reporting coverage it did not have. The ledger now states the rule it keeps re-deriving: before trusting a green check, make it go red on something you know is there. Verified: spec 282 files / 7141 tests, `tsc --noEmit` clean, all 8 generated artifacts current (the only regeneration is the two envelope key tables), all 15 `check:*` gates green. Claude-Session: https://claude.ai/code/session_01WnqGjQFQMqd5k81LYV8SCY Co-authored-by: Claude <noreply@anthropic.com>
…e script/subflow config at execute time (#4343) (#4516) * feat(spec,automation)!: converge `script` to a function call and parse script/subflow config at execute time (#4343) A `script` node had four ways to name what it ran and only one of them ran anything. `actionType: 'email' | 'slack'` were logger-backed stubs that wrote a line, reported success and delivered nothing under any configuration, with `template` / `recipients` / `variables` addressing a message no channel sent. Inline `config.script` was recognized and never executed (no server-side JS sandbox). Every other `actionType` value was shorthand for a registered-function name, and `'invoke_function'` was a marker that named nothing on its own. All five keys are tombstoned (`retiredKey`) and `config.function` becomes required, which is also what made the contract parseable: while the legal key set depended on `actionType`, a flat parse would either reject valid shapes or wave everything through. `script` and `subflow` now run their config through the execute-time contract parse #4277 gave the flat builtins — a violation refuses the node as a guard, un-routable by a `fault` edge (#3863). `decision` stays export-only: its one key is optional, so a parse would check nothing. The ADR-0087 D2 conversion `flow-node-script-branch-keys-removed` rewrites stored sources — a shorthand `actionType` moves into `function` (that is what it named) unless `function` already won; the other keys drop, nothing having read them. Retired from the load path with the rest of the keys retired for misdescribing themselves, so `os migrate meta --from 16` is what rewrites an authored source. `registerFlow` still replays it (#3903 — a stored row has no author to teach), so an old email-stub node arrives stripped and then refuses for naming no callable, where it used to report success. Also: the `SCRIPT_BUILTIN_ACTION_TYPES` / `SCRIPT_INVOKE_FUNCTION_ACTION_TYPE` constants and `ScriptBuiltinActionType` are removed; `os validate` names a retired key and its replacement; the examples move to `notify` (real delivery) and `http` (Slack webhook), and the showcase gains a registered function so its `script` node demonstrates the one form that works. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ct9NXp2JumjKuARtQnrbPf * fix(spec): accept a lowered handler ref in `functions`, so `defineStack({ functions })` survives a build (#4343) `objectstack build` lowers every inline callable to a serialisable string ref BEFORE the stack is parsed — it must, since `z.function()` wraps callables and would break the ref mapping — so a built manifest holds `{ myFn: 'myFn' }`. `FlowFunctionEntrySchema` accepted only a function or a `{ handler, effect }` declaration, so the parse rejected what the build had just produced: a documented, first-class authoring mechanism could not survive a build. Nothing had noticed because no bundled example used `functions`. #4343 turns that from latent into blocking: `config.function` becomes the only thing a `script` node runs, so registering one is now mandatory for any app with a script node — which is what the showcase demo in this branch hit. `Hook.handler` already declared exactly this pair (a string post-build, an inline function pre-build), so this puts `functions` on the platform's existing shape rather than a new one. A string carries no callable and `normalizeFlowFunctionEntry` still drops it by design — the real functions ride in the sibling ESM module the build emits and are merged by name — so hand-authoring one registers nothing and fails loudly at execute rather than silently. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ct9NXp2JumjKuARtQnrbPf --------- Co-authored-by: Claude <noreply@anthropic.com>
…#4462, #4432, #4455, #4449) (#4520) * fix(cli,lint): run validateFormLayout, and close the rule registry from the other side (#4449) `validateFormLayout` was implemented, unit-tested, exported and given published rule ids — and no command ever called it. A whole-repo search found the implementation, the barrel export line and its own unit test, and nothing else: the rule ran on zero stacks for as long as it existed. Two changes: * register it in `AUTHORING_RULES` as `advisory` on all three commands. It walks structured metadata only (no lazy dependency), so `os validate`, `os build` and `os lint` pay nothing measurable for it. * add the reverse closure to the wiring guard. Every invariant #4409 shipped starts FROM a registry and looks at the commands, which cannot see a rule that never entered a registry — the same blind spot as #4402's name list, one layer up. The guard now subtracts both registries from the `validate*` / `lint*` symbols on `@objectstack/lint`'s public barrel; the difference must be empty or ledgered with a reason in `UNWIRED_RULE_LEDGER`, which ships empty because today's difference was exactly this one rule. The new tests fail without the registry entry: the closure reports `validateFormLayout` as unwired, and the liveness test asserts the entry's own `run` adapter returns both findings for a stack that earns them — membership alone is not evidence a rule produces output. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * fix(spec): a stored reference holding an embedded record is not a valid id (#4455) `os migrate value-shapes` is the evidence half of the ADR-0104 D1 per-deployment gate, and the scan's own header names the case it exists for: "a `location` stored as `{latitude, longitude}` or a `lookup` holding an expanded record object". The second case was never detected. `ReferenceIdValueSchema` was `z.string().min(1)`, and in a SQL deployment a legacy embedded reference reaches storage as JSON TEXT — a non-empty string. So a deployment carrying exactly the values the gate exists to find ran the scan, was told it was clean, and closed the gate with `--apply`; and because the scan deliberately imports the write-path predicate, the write path was equally blind, so the value also survived future writes. `ReferenceIdValueSchema` now rejects a value whose first non-space character is `{` or `[`, in the expanded form too — `$expand` produces an object, never its serialization. Deliberately narrower than an id charset. `FileReferenceIdValueSchema` can bound its alphabet because a `sys_file` id is minted by the platform and nothing else; a reference id is whatever the target object's key holds, including an external key an ADR-0015 federated datasource supplies. So this rejects the shape that is provably not an id and leaves the alphabet to the object that owns it — `CB0-2026-0001`, `SFDC:001xx…` and `ops/eu-west/tenant-7` stay valid, and the tests pin that. Regression coverage is at the GATE, not just the schema: the scan test plants the serialized embedded record, asserts it is counted, and asserts `valueShapeScanPassed()` is false — the deployment may not record the flag — then asserts the same value is a write rejection under strict, so the scan and the validator still answer with one predicate. Reaches authors through the ADR-0104 warn-first path until a deployment opts into strict, so nothing starts rejecting writes on upgrade. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * fix(metadata-protocol): one canonical type key at the /meta boundary (#4432) #3985 taught the per-type gates to accept both spellings of the `/meta` type segment. It did not FOLD them, so `/meta/actions/x` and `/meta/action/x` addressed two namespaces and the layers below disagreed about which one an item lived in — `SysMetadataRepository` folded to singular on its own, while the authorization tier above it (`isOverlayAllowed`, `isArtifactBacked`), the registry heal below it (`restoreArtifactRegistryView`) and the list hydration all read the caller's spelling. The damaging half was the hydration. `getMetaItems` registered overlay rows back into the SchemaRegistry under `request.type`, so one plural-spelled read minted a PLURAL registry entry; from the next read on `listItems('actions')` was no longer empty, the singular fallback that had been supplying every code-authored action stopped running, and one overlay row hid the entire code-authored listing — on a spelling no DELETE addresses, so it outlived the delete that was meant to lift it and left listing and dispatch disagreeing about a removed item. `saveMetaItem`, `getMetaItem`, `getMetaItems`, `getMetaItemLayered`, `getMetaItemCached` and `deleteMetaItem` now fold the type to its canonical singular as their first act. Reads of data AT REST keep the other-spelling fallback: rows written under a plural `type` before this fix are real and nothing rewrites them on upgrade. What changed is that nothing WRITES or REGISTERS a non-canonical key any more. Regression tests fail without the fold: a plural-spelled read mints a phantom `actions` registry entry and the second read drops the code-authored actions, and `getMetaItem` echoes back the caller's spelling so a client can round-trip it into a second namespace. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * fix(objectql,service-datasource,runtime): a datasourceMapping rule is routing, not a hint (#4462) Measured on `main` during the v17 verification: map an object to a Postgres datasource with a bad URL and the boot SUCCEEDS, `/ready` answers 200, the datasource name appears in ZERO log lines, the write returns 201 — and the row is physically in the DEFAULT store. The operator finds out by opening the database they declared and finding it empty. Two causes, one per layer, and each is what makes fixing the other correct: * `ObjectQLEngine.getDriver` step 2 read `mapped && drivers.has(mapped)`, so a MATCHED mapping rule naming a datasource with no live driver fell silently through to the default driver. It now throws — `DatasourceUnavailableError` when the connect layer recorded a verdict (#3828), otherwise an error naming the object, the datasource and the two remedies. `default` still resolves onward: the default driver keeps its natural name (#3826), so `drivers.has('default')` is false by construction and step 5 IS how routing to it works. * ADR-0062 D2's phase-1 note deliberately excluded "mapped" from the auto-connect gate, to keep `examples/app-crm` byte-for-byte unchanged. That note priced only one side. Gate (d) now fires when a mapping rule routes at least one object to a datasource, and a `declared-auto` failure is FATAL with an operator-readable reason — the same call gate (b) already makes for an explicit `object.datasource` binding, correct for (d) now that routing no longer supplies a fallback. `OS_ALLOW_DRIVER_CONNECT_FAILURE` still degrades. The mapped-object list comes from the engine's own matcher (`ObjectQLEngine.resolveMappedDatasource`, newly public) via `connectDeclared({ mappedObjects })`. The connection service never re-derives rule matching: two matchers drifting by one clause would connect a datasource routing never uses, or route to one nothing connects — the defect again. `examples/app-crm`'s mapping is DELETED, and that is what keeps the example unchanged rather than what breaks it. Its `namespace: 'crm'` rule never matched (`namespace` is deprecated; no object sets it) and its `default: true → crm_primary` rule routed everything to an unconnected `:memory:` datasource, i.e. to the default store by fall-through. Honouring it would move the whole app — platform objects included — onto a database empty on every boot. Verified against a real boot on a private port, not only in unit tests: * unchanged CRM example boots healthy; crm_primary/crm_analytics stay `unvalidated` (metadata-only) exactly as before; * with a mapping to `postgres://…@127.0.0.1:1/nonexistent_db`, boot exits 1 with "1 object(s) are routed to it by a datasourceMapping rule (crm_account) and have no fallback datasource — their reads/writes would otherwise land in a DIFFERENT database than the one they declare ⇒ fail-fast per ADR-0062 D5"; * under OS_ALLOW_DRIVER_CONNECT_FAILURE=1 the degraded-boot banner carries the same sentence and the mapped object's seeds fail instead of silently populating the default store. ADR-0062 D2 carries the amendment; the docs page and the data skill now state that a mapping rule is routing and fails the boot when it cannot be honoured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD --------- Co-authored-by: Claude <noreply@anthropic.com>
* wip: rescue in-progress work before container restart loss * fix(objectql): a lookup id that resolves to nothing is refused on write (#4441) A `lookup` accepted an id that exists in no row of the object it declares, on both platform and application objects: POST /data/sys_position_permission_set {"position_id":"…","permission_set_id":"ps_does_not_exist_at_all"} → 200 POST /data/showcase_task {"title":"…","project":"proj_does_not_exist","status":"backlog"} → 200 The field metadata is unambiguous (`type: lookup`, `required: true`, `reference: 'sys_permission_set'`) and `deleteBehavior: 'set_null'` shows the platform already reasons about this edge on the DELETE side. Only the insert side never checked. On the RBAC link tables that is a security-surface record that resolves to nothing: an administrator auditing permissions sees a binding whose target cannot be inspected, and the audience-anchor gate must resolve that very set to evaluate the grant — so the row is an unevaluable gate input, not just untidy. Enforced in the engine, so every write path inherits it (REST, flows, actions), with the refusal carried in the `fields[]` envelope a `required` violation already uses: `{ field, code: 'reference_not_found', value, message }` → `400 VALIDATION_FAILED`. `reference_not_found` was already a catalogued `FieldErrorCode` with no emitter; it has one now, plus its message in the four platform locales. Scope kept deliberately narrow: - **Caller-supplied keys only.** `owner_id` / `organization_id` / `created_by` / `updated_by` are lookups too, written by hooks and middleware; re-validating them would turn a platform stamp into a caller-facing rejection. - **Non-system writes only**, like every other write-path guard here (`stripReadonlyFields`, `stripReadonlyForInsert`). Seed replay, package install and boot provisioning legitimately write in an order that resolves only once the batch completes; failing them closed turns an ordering detail into a boot failure. The residual — an `isSystem` caller can still write a dangling reference — is recorded rather than silently accepted. - **Empty is not a reference.** `null` / `undefined` / `''` mean "no link" — exactly what `deleteBehavior: 'set_null'` produces. - **Fails OPEN when the target cannot be checked** (unregistered object, no driver, probe throws). An integrity check that cannot run must not invent a rejection; the alternative converts a connectivity problem into data loss. - **The probe is unscoped.** Existence is a fact about the database, not about the caller's visibility — the same distinction #4435's probe turns on. A scoped probe would refuse a link to a permission set the caller cannot READ, which is ordinary under RLS and would break the platform's own admin flows. Whether the caller may create the binding is the RBAC/RLS layer's decision. Wired at BOTH update call sites, single-id and bulk — PD #10's own worked example (#3106) is a guard that reached only the single-id path. No new authorable spec key and no protocol change: the issue's "optional dangling references should be opt-in" would need one, so it is NOT implemented here and is left as a decision for the maintainers. Tests: `engine-lookup-referential-integrity.test.ts` (10) — the RBAC link table and an ordinary object, insert / update / bulk update, multi-value elements, clearing a lookup, the system-context exemption, server-stamped lookups, and the fail-open target. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * fix(objectql): the audit anchor is engine-owned — a declared created_at cannot loosen it (#4447) `created_at` accepted a client-supplied value on an ordinary REST write and PERSISTED it: PATCH /api/v1/data/showcase_task/<id> {"progress":42,"created_at":"1999-01-01T00:00:00Z"} → 200, and the row reads back 1999-01-01 from then on Any authenticated caller who could edit a record could forge the anchor of the audit timeline — the field historical import deliberately preserves, dashboards bucket on, and an auditor reads to establish when a record came into existence — silently, in the same request as a legitimate edit, with no diagnostic. Its two siblings only LOOKED protected. The audit hook force-advances `updated_at` / `updated_by` on every update, so a forged value there is overwritten rather than refused. `created_at` is insert-only, so nothing overwrote it: one field out of the trio was genuinely unguarded, which is why this read as "one field, not a posture". ## Root cause Not a missing definition — `AUDIT_FIELD_DEFS.created_at` has carried `readonly: true, system: true` all along. The hole is that it never applied: `applySystemFields` injects an audit field only when the object does not already have one, and the merge (`{...additions, ...schema.fields}`) lets a declared field WIN. Correct for an authored business field; wrong for a family whose whole point is that the engine owns it. `showcase_task` declares no `created_at` in source, yet the built artifact ships one: "created_at": {"label":"Created At","type":"datetime","readonly":false, …} — a materialized field carrying only FieldSchema DEFAULTS. It shadowed the engine-owned definition, so `stripReadonlyFields` had nothing to key off and the forged value went straight through. No `droppedFields` either, because from the platform's point of view nothing was dropped — which is also why the #3794 contract had no live producer on this axis to test against. ## Fix The audit family's GOVERNANCE is not authorable. A declared audit field now keeps everything presentational (label, description, hidden, group, ordering …) and has `type` / `readonly` / `system` / `reference` forced to the platform's values, derived from `AUDIT_FIELD_DEFS` rather than restated so the two cannot drift. Defensive by design: it closes the class for any producer of a bogus audit field — artifact, stored metadata, AI-authored object, hand-written YAML — not just the one that surfaced it. Deliberate back-dating is untouched: `preserveAudit` (#3479/#3493) and `isSystem` writes still reinstate the original timeline, because both are checked downstream of `readonly`, not by it. Forcing `readonly`/`system` also only ever RELAXES validation — `validateRecord` skips system/readonly fields — so no previously-accepted write starts failing. ## Verified on a real boot `pnpm dev -- --fresh -p 38106`, showcase, ordinary admin session, the issue's own curl: before {"created_at":"2026-06-22T00:00:00.000Z","progress":55} PATCH {"progress":42,"created_at":"1999-01-01T00:00:00Z"} → 200 droppedFields: [{"fields":["created_at"],"reason":"readonly"}] after {"created_at":"2026-06-22T00:00:00.000Z","progress":42} The legitimate half of the request still lands, the anchor does not move, and the drop is now REPORTED — the `droppedFields` contract's first live producer here. Tests: `engine-audit-anchor-write.test.ts` (10) — the update strip, the `droppedFields` report, the whole trio behaving alike, the bulk call site, the `preserveAudit` and `isSystem` escape hatches, and the shadowing root cause reproduced with the artifact's field verbatim. Verified failing on the three root-cause cases before the fix. Known follow-up, NOT fixed here: `/api/v1/meta/objects/showcase_task` still reports `readonly: false` for `created_at` — that surface reads the artifact rather than the registry, so it now disagrees with the enforcement. Filed separately; a machine-readable surface must not lie (AGENTS.md, Route & surface ownership #4). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * fix(objectql): check only the reference the CALLER named, and narrow the audit override (#4441, #4447) Two corrections found by running the full objectql suite (1554 tests). ## The reference check validated platform-derived ids (#4441) Filtering by supplied KEY was wrong. A form serializes an unpicked control as an explicit `null`, and `applyFieldDefaults` then fills it from `defaultValue` — including the `current_user` token (#2706). The key IS in the payload, but the id that lands is the platform's, so the check reported a server-derived value as the caller's bad reference and rejected an ordinary insert whenever the acting principal had no row in the target object. The value is still read from the post-normalization payload (multi-value strings are already split by then), but WHETHER to check is now decided by the caller's own RAW value being non-empty. `engine.test.ts`'s #2706 case was the one that caught it. ## The audit override forced more than the defect required (#4447) Forcing `type`/`reference` broke `registry.test.ts`'s "does NOT overwrite author-declared audit fields", and that test is right: an external/federated object legitimately maps its audit column to a differently-typed remote column, and #4447 is about WRITABILITY, not storage shape. The override now carries only `readonly` and `system` — the keys that decide who may write it — so the author keeps `type`, `label`, `description`, `hidden`, `group` and the rest. The pre-existing test passes unchanged. Suite green: objectql 95 files / 1554 tests. New case pinned in `engine-lookup-referential-integrity.test.ts` (11) for the derived-value direction, both for an explicit `null` and an omitted key, plus the proof that a value the caller DID name is still checked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * docs: correct the two pages this change makes untrue (#4441) Checked the three hand-written pages the docs-drift report flagged as actually describing behaviour these fixes change (the other ~107 are indirect `@objectstack/spec` references). **`api/error-catalog.mdx` — was wrong.** It documented `INVALID_REFERENCE` as what a `lookup`/`master_detail` pointing at a missing record answers with. That code has ZERO producers in source, and now that dangling references are actually refused, the real answer is `400 VALIDATION_FAILED` with `fields[].code === 'reference_not_found'`. The entry now says so, tells callers which code to branch on, and a callout carries the real envelope plus the three deliberate non-rejections (empty value, `isSystem` write, uncheckable target). `INVALID_REFERENCE` itself is left in place — it is a `StandardErrorCode` member, and removing it is a spec change, not a docs fix. **`data-modeling/fields.mdx` — was silent.** The lookup section documented `reference` and `deleteBehavior` without saying whether the target has to exist; it does now, so authors need it. Added a short "Referential integrity" note with the rejection shape, the bulk-update coverage, and the fact that clearing a relationship is not a dangling reference — plus the distinction from `deleteBehavior`, which governs the other half of the same contract. **`protocol/objectql/schema.mdx` — accurate as-is, unchanged.** It documents the `reference` PROPERTY in a property table, not write-time semantics, so it makes no claim this change falsifies. Duplicating the note there would be two places to keep in sync. Also checked `droppedFields`, since #4447 gives that contract its first live producer on the audit axis: the only non-generated mention is in `protocol/kernel/http-protocol.mdx`, about the CORS-exposed `x-objectstack-dropped-fields` header, and it is correct. `releases/v17.mdx` mentions it too but is deliberately untouched — release notes are written centrally at release time (CLAUDE.md), never as a rider on a code PR. `content/docs/references/` is auto-generated and was not touched. `pnpm check:doc-authoring` green (215 files). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * fix(objectql): a readonly lookup is not the caller's reference to answer for (#4441) The dogfood gate rejected ordinary metadata authoring — package create, publish and clone all failed with: ValidationError: Recorded By: no sys_user record has id "system" `sys_metadata_history.recorded_by` is `Field.lookup('sys_user', { readonly: true })`, and the metadata repository fills it with `actor ?? 'system'` — a SENTINEL STRING, not a user id — on a write that does not carry `isSystem`. So the PR body's claim that "non-system writes are caller writes" was simply not true of the running platform, and only the real gate could show that: the unit suite's fakes never write a sentinel into a lookup. The narrowing follows from the check's OWN stated scope rather than being an exemption bolted on to go green. `stripReadonlyFields` removes a non-system caller's value from a readonly field before the write, and the create ingress does the same (`stripReadonlyForInsert`, #3043). So a value still sitting in a readonly field at this point was written by the PLATFORM by construction — "the reference the caller named" was never going to include it. This does not weaken the fix. The two fields #4441 names — `sys_position_permission_set.permission_set_id` and `showcase_task.project` — are ordinary author-facing lookups with no `readonly`, and both stay enforced; a new case asserts exactly that, so a future widening of this skip cannot quietly swallow them. The sentinel-in-a-lookup is a genuine modelling wart — a lookup column holding a non-id — and is the same class #4441 is about, written by the platform rather than a caller. It is filed separately; rejecting the platform's own write is not the way to report it, and changing what `recorded_by` stores is not this change's call. objectql: 23 tests across the two new suites green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * test(dogfood): the field-zoo matrix round-trips REAL references, not synthetic ids (#4441) `field-zoo-roundtrip` seeded its three relational fields with ids that exist in no row: { field: 'f_lookup', write: 'acc_synthetic_0001' } { field: 'f_master_detail', write: 'proj_synthetic_0001' } { field: 'f_tree', write: 'cat_synthetic_0001' } under a comment reading "FK enforcement is off in this harness". That comment described a HOLE — precisely the one #4441 closes — so the fixture depended on the defect the platform now prevents, and the create started failing with `reference_not_found` for all three fields at once. This is the fixture being wrong, not the check being strict, so the data is what changes. The suite now creates a real row in each target object and substitutes its id. What the file actually proves is unchanged — an id string must round-trip as the same id string, the #2004 type-fidelity guard — and it now proves it over references that resolve, which is strictly stronger. The matrix keeps a `REFERENCE_PLACEHOLDER` symbol rather than a magic string, so a future edit cannot typo its way back into writing a literal id, and the placeholder can never reach the wire: substitution is keyed on identity. `REFERENCE_TARGETS` is ORDERED with a body factory because the targets reference each other — `showcase_project` declares a REQUIRED lookup to `showcase_account`, so the account must exist first and the project must be given its real id. Seeding them in the wrong order now fails loudly instead of writing a project that points at nothing, which is a small proof of #4441 in its own right. `status: 'planned'` is the state machine's declared initial state; `active` is refused with `invalid_initial_state`. Verified: `field-zoo-roundtrip.dogfood.test.ts` 46/46 green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * fix(dogfood): the matrix placeholder must satisfy BOTH consumers, not just one (#4441) Fixes the shard-1 regression 7bcd40d introduced. `field-zoo.matrix.ts` has TWO consumers, and I only checked one: - `field-zoo-roundtrip.dogfood.test.ts` — drives the vectors over real HTTP and (as of 7bcd40d) substitutes a seeded reference id; - `field-zoo-value-shape.test.ts` — the ADR-0104 contract⇔oracle interlock, which parses every `write` vector against `valueSchemaFor(type, 'stored')` WITHOUT booting a stack, and therefore never substitutes. The `Symbol` placeholder satisfied the first and broke the second with `expected string, received symbol`. Because the two live in different FILES and dogfood shards by file, that surfaced as "shard 2 fixed, shard 1 regressed" — one root cause wearing two masks, not two problems. The placeholder is now a STRING, which is what a reference's stored form actually is, so the contract test parses it like any other id vector. And substitution is keyed on the field appearing in `REFERENCE_TARGETS` — the authoritative list — rather than on comparing against the placeholder value, so the placeholder is documentation rather than a control signal and cannot leak to the wire even if someone edits it. Verified together this time, in one tree: - both matrix consumers in one run: 91/91 - dogfood shard 1/2: 38 files passed - dogfood shard 2/2: 37 passed, 1 skipped Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD --------- Co-authored-by: Claude <noreply@anthropic.com>
…line `main` moved to 20b1a9e (#4511 and friends) and changed the `@objectstack/spec` public surface, which collided with this branch's regenerated `packages/spec/api-surface.json` — the only conflicted file in the merge. That file is generated, so neither side is the answer: picking `ours` would erase main's surface changes and picking `theirs` would erase the percent-scale exports, and both would leave a baseline that no longer describes the built package. Resolved by rebuilding `spec` from the merged sources and re-running `gen:api-surface`. Against main's baseline the result adds exactly the four percent-scale exports — `PercentScale`, `PercentScaleFieldMeta`, `percentScaleOf`, `emptyGroupValueFor` — and nothing else, so main's own additions and removals (4606 → 4500 exports) all survive. `api-surface-signatures.json` came back byte-identical on both sides. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S9TVFwXGsXqR3SD5e2qAU8
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 21 package(s): 129 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
Not mergeable through GitHub — see "How to land this" below. This PR holds a verified conflict resolution for #4442; it cannot be squash-merged without defeating its own purpose.
Resolves the merge conflict that put #4442 into
dirty.Why #4442 went dirty
#4442 was
cleanagainstmainatfd3013a.mainhas since moved to20b1a9e(#4511 and friends), which reshaped the@objectstack/specpublic surface — and #4442 now carries a regeneratedpackages/spec/api-surface.jsonof its own. Both sides rewrote the same lines of the same generated file:That was the only conflicted path. Everything else — including
service-analytics/src/analytics-service.tsandplugin.ts, which both branches touched — auto-merged.How it's resolved
Neither side is the answer for a generated file.
--ourswould erase main's surface changes;--theirswould erase the percent-scale exports; both would leave a baseline that no longer describes the built package, which is precisely the state thecheck:api-surfacegate exists to catch. So the resolution is to rebuildspecfrom the merged sources and re-rungen:api-surface— let the generator state the truth about the merged tree.The result is verifiable rather than asserted. Against main's own baseline, the merged branch differs by exactly the four percent-scale exports and nothing else:
Main's additions and removals all survive — the export count moves
4606 → 4500in step with main.api-surface-signatures.jsoncame back byte-identical on both sides.Reading the diff — the file count is an artifact
GitHub diffs this PR against its base,
claude/percent-scale-3136atbb79c30, and that commit is 459 files behindmain. Mergingmainin therefore renders every one of main's commits sincead5fe25as though it were authored here. It was not.Measured against
main, the merged branch adds 16 files — #4442's own change, plus two files from this PR:To see what this PR actually decides, diff the branch against
main, not against the PR base:Note that
git show --staton the merge commit is not the right lens — for a merge it prints the full first-parent diff (447 files), not the resolution.The semantic-conflict check
A textual auto-merge is not proof of a semantic one. #4442 renames
measureCurrency→sourceFieldMetainanalytics-service.ts, and main independently added__tests__/measure-source-field-gate.test.tsto the same package — the classic shape for a merge that compiles on paper and breaks in fact. Verified directly: main's new test references neither name, andmeasureCurrencyhas no remaining references anywhere inpackages/.Verification (post-merge, on the merged tree)
spec check:api-surfacepublic API surface + factory signatures unchanged ✓spec typecheck(tsc --noEmit)specsrc/datasuiteservice-analyticsfull suitemeasureCurrencydangling referencesHow to land this
Do not squash-merge this PR. This repository disallows merge commits (
405: Merge commits are not allowed), and squashing would flatten main's history into a single commit without recordingmainas an ancestor — leavingmerge-base(main, percent-scale-3136)stuck atad5fe25, ballooning #4442's own diff, and likely re-conflicting onapi-surface.json. That undoes the fix.The correct way to land it is to push the merge commit
3144a11straight ontoclaude/percent-scale-3136— exactly what GitHub's own "Update branch" button does:That is a fast-forward —
3144a11already hasbb79c30as its first parent — so nothing is rewritten. #4442 returns tomergeableand its diff stays at its own 16 files.🤖 Generated with Claude Code
https://claude.ai/code/session_01S9TVFwXGsXqR3SD5e2qAU8