Skip to content

fix(data): 审计锚点归引擎所有,lookup 必须能解析 (#4447, #4441) - #4511

Merged
os-zhuang merged 8 commits into
mainfrom
claude/v17-verification-defects-gnf9e6-audit-anchor-v2
Aug 1, 2026
Merged

fix(data): 审计锚点归引擎所有,lookup 必须能解析 (#4447, #4441)#4511
os-zhuang merged 8 commits into
mainfrom
claude/v17-verification-defects-gnf9e6-audit-anchor-v2

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

v17 验证清单(#3909)里两个写入路径的契约漏洞。与 PR #4496 同属一张卡,因为 #4496 已进入合并队列无法再推送,这两条另起分支。

Fixes #4447
Fixes #4441


#4447 —— created_at 可被客户端在普通 PATCH 里写入并持久化

任何能编辑该记录的已认证调用方都能伪造审计时间线的锚点——历史导入特意保留的字段、仪表盘分桶依据、审计人员用来确定记录何时存在的字段——而且是在一次合法编辑的同一个请求里静默完成,没有任何诊断信息。

为什么只有这一个字段中招

它的两个兄弟只是看起来被保护了:审计 hook 在每次 update 都强制推进 updated_at / updated_by,伪造值被覆盖而非被拒绝。created_at 只在 insert 时写入,所以没有任何东西覆盖它。三件套里真正无防护的就这一个。

根因(不是定义缺失)

AUDIT_FIELD_DEFS.created_at 一直带着 readonly: true, system: true。漏洞在于它从未生效applySystemFields 只在对象尚未拥有该字段时注入,而合并顺序 {...additions, ...schema.fields} 让「已声明的字段」获胜。对作者自定义的业务字段这是对的,对一个存在意义就是「引擎独占」的字段族则是错的。

showcase_task 源码里并没有声明 created_at,但构建产物里有——一个只带 FieldSchema 默认值readonly: false)的物化字段。它遮蔽了引擎自己的定义,于是 stripReadonlyFields 无从判断,伪造值直接落库。也因此没有 droppedFields:从平台视角看确实什么都没被丢弃,这同时解释了为什么 #3794 的契约在这条轴上一直没有活的生产者可供测试。

修法

审计字段族的治理权不可作者化:已声明的审计字段保留全部表现层设置(label、description、hidden、group、排序……),仅 readonly / system 由平台强制。

范围刻意收窄到只强制这两个键。第一版还强制了 type/reference,被 registry.test.ts 既有用例挡下来了,而那个用例是对的——外部/联邦对象完全可能把审计列映射到类型不同的远端列,而 #4447 讲的是可写性,不是存储形状。

回填路径不受影响:preserveAudit#3479/#3493)和 isSystem 写入仍可重建原始时间线,因为二者是在 readonly 之后判定的。强制 readonly/system 也只会放宽校验(validateRecord 跳过 system/readonly 字段),不存在原本能通过的写入开始失败。


#4441 —— lookup 接受在被引用对象里根本不存在的 id

字段元数据毫不含糊(type: lookuprequired: truereference: 'sys_permission_set'),deleteBehavior: 'set_null' 说明平台在 delete 一侧已经考虑过这条边界,只有 insert 一侧从未检查。

在 RBAC 关联表上,一条悬空行是指向虚无的安全面记录:管理员审计权限时看到的绑定,其目标无法被查看;而受众锚点闸门恰恰要解析那个权限集才能评估授权——所以它是一个无法求值的闸门输入

修法

在引擎层强制,所有写入路径(REST / flow / action)一并继承,拒绝信封复用 required 违规已有的 fields[] 形状。reference_not_found 本就是 FieldErrorCode 目录成员,此前没有任何生产者;现在有了,并补齐四个平台语种消息。两个 update 调用点都接上了,单条与批量。

豁免边界(逐条经真实写入路径反证,非复述)

边界 依据
readonly 字段跳过 原先过严,已收窄。 dogfood 实测:sys_metadata_history.recorded_bylookup('sys_user', {readonly:true}),平台以 actor ?? 'system' 填入哨兵字符串,且该写入不带 isSystem。「非 system 即调用方」这一前提被真实平台证伪。收窄依据来自检查自身的定义域:stripReadonlyFields / stripReadonlyForInsert 保证非 system 调用方的值不可能存活在 readonly 字段里。
只作用于非 system 写入 成立。真实启动日志 grep -c reference_not_found0,种子/包安装未受影响。
只校验调用方真正给出的值 原先过严,已修。表单把未选控件序列化为显式 nullapplyFieldDefaults 随后用 defaultValue(含 current_user#2706)回填——key 在 payload 里但 id 是平台的。由 engine.test.ts#2706 用例抓出。
空值不是引用 成立,null / '' / [] 均跳过并有用例。
无法检查时 fail OPEN 成立,目标未注册/无 driver/探测抛错均放行并有用例。
批量与单条一致 成立,两个调用点均接入并各有用例。
存在性探测不带调用方作用域 成立。存在性是关于数据库的事实;带作用域会拒绝「链接到调用方读不到的权限集」,在 RLS 部署里很平常。是否有权创建绑定仍由 RBAC/RLS 层决定。

对 dogfood 夹具的改动,以及为什么那些数据本来就是错的

field-zoo 矩阵原先往 lookup / master_detail / tree 里写 acc_synthetic_0001在任何行里都不存在的 id,注释写着:

// FK enforcement is off in this harness, so this asserts value fidelity

这句注释记录的正是 #4441 要关掉的那个洞——夹具依赖着平台现在要禁止的缺陷。该文件真正要证的是 #2004 的类型保真:id 字符串必须原样往返;用真实 id 同样能证,而且更强。两个独立佐证说明是数据错而非检查严:showcase_project 自身声明了对 showcase_account必填 lookup(所以播种必须有序),且 status: 'active'invalid_initial_state 拒绝——都是合成 id 一直在绕开的既有约束。

夹具现在为每个目标对象创建真实行并使用其 id。占位符是字符串而非 Symbol:矩阵有两个消费者,field-zoo-value-shape.test.ts(ADR-0104 契约⇔预言机互锁)不启动栈、也从不替换,直接把每个 write 向量喂给 valueSchemaFor(type,'stored')。Symbol 占位符满足了 HTTP 那个、却打断了契约那个(expected string, received symbol)——而两者在不同文件、dogfood 按文件分片,于是表现为「分片 2 修好、分片 1 回退」。这是一个根因的两副面孔,已对称修复并在同一棵树里同时验证两个消费者与两个分片


真实启动验证(针对 HEAD 0250afd9 的前一提交 7bcd40dd 重新采集)

更正说明:本节最初的报文采集自提交 12a698129,虽为真实实测(dev38106.log 存有 CLI 自身打印的 ➜ API: http://localhost:38106/,响应体存于 patch.json/ok.json),但其后又落了两个改变行为的提交,因而不再能证明当前代码。已在 38107 端口重新启动并重新采集,下方为新报文。旧报文已移除,避免让评审者以为验的是当前版本。

服务器确实监听:健康检查 HTTP 200,启动日志含 ➜ API: http://localhost:38107/

#4447showcase_task/4COiRR30W9Hl6M79):

before  {"created_at":"2026-06-22T00:00:00.000Z","progress":55}
PATCH   {"progress":42,"created_at":"1999-01-01T00:00:00Z"} → HTTP 200
        droppedFields: [{"object":"showcase_task","fields":["created_at"],"reason":"readonly"}]
after   {"created_at":"2026-06-22T00:00:00.000Z","progress":42}

合法的那一半照常落库,锚点纹丝不动,丢弃被上报了

#4441

POST /data/showcase_task {"project":"proj_does_not_exist", …} → HTTP 400
{"error":"Project: no showcase_project record has id \"proj_does_not_exist\"",
 "code":"VALIDATION_FAILED",
 "fields":[{"field":"project","code":"reference_not_found","label":"Project",
            "constraint":{"target":"showcase_project"},"value":"proj_does_not_exist"}]}

POST /data/sys_position_permission_set {"permission_set_id":"ps_does_not_exist_at_all", …} → HTTP 400
{"error":"Permission Set: no sys_permission_set record has id \"ps_does_not_exist_at_all\"",
 "code":"VALIDATION_FAILED",
 "fields":[{"field":"permission_set_id","code":"reference_not_found","label":"Permission Set",
            "constraint":{"target":"sys_permission_set"},"value":"ps_does_not_exist_at_all"}]}

POST /data/showcase_task {"project":"7K9I0eUpUbmyHNXu", …} → HTTP 201  (id b4rpRzmPSs0CmeQ1)
启动日志 grep -c reference_not_found → 0

用完已 kill,端口已确认释放。


边界:没有做、需要拍板的事

顺带发现(PD #10

门禁

门禁 结果
pnpm typecheck ✅ 122/122 tasks
pnpm --filter @objectstack/objectql test ✅ 95 files / 1554 tests
pnpm --filter @objectstack/spec test ✅ 275 files / 6958 tests
pnpm --filter @objectstack/spec check:generated ✅ 8/8 产物均最新
pnpm check:doc-authoring ✅ 215 files clean
Dogfood shard 1/2 ✅ 38 files passed
Dogfood shard 2/2 ✅ 37 passed / 1 skipped
两个矩阵消费者同跑 ✅ 91/91
新增 engine-audit-anchor-write.test.ts ✅ 10(修复前 3 条根因用例红)
新增 engine-lookup-referential-integrity.test.ts ✅ 13(修复前红)
真实启动(-p 38107,HEAD 代码) ✅ 两个 issue 的原始 curl 均确认修复,已 kill
changeset .changeset/audit-anchor-and-lookup-integrity.md

文档:api/error-catalog.mdx(该错误码此前无生产者,且原文把悬空引用记成 INVALID_REFERENCE——实际是 VALIDATION_FAILED + fields[].reference_not_found)与 data-modeling/fields.mdx(补引用完整性一节)已更正;protocol/objectql/schema.mdx 核对后本就准确,未改;content/docs/references/ 为自动生成,未手改。

claude added 4 commits August 1, 2026 13:43
…te (#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
…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
…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
@vercel

vercel Bot commented Aug 1, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 1, 2026 3:53pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:system tests tooling labels Aug 1, 2026
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/objectql, @objectstack/dogfood, @objectstack/spec.

111 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql, packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/permissions/authorization.mdx (via packages/qa/dogfood, @objectstack/spec)
  • content/docs/permissions/delegated-administration.mdx (via packages/qa/dogfood)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

claude added 2 commits August 1, 2026 14:15
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
…wer 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
@github-actions github-actions Bot added size/xl and removed size/l labels Aug 1, 2026
claude added 2 commits August 1, 2026 15:38
…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
… 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:system size/xl tests tooling

Projects

None yet

2 participants