Skip to content

feat(automation): update_record/create_record 步骤对被静默剥离的写入字段挂 warning(#3407) - #3413

Merged
os-zhuang merged 3 commits into
mainfrom
claude/update-record-silent-field-strip-l77rag
Jul 24, 2026
Merged

os-zhuang merged 3 commits into
mainfrom
claude/update-record-silent-field-strip-l77rag

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #3407(从 #3356 拆出的可观测性缺口)。

问题

update_record 节点无条件返回 success,即使请求写入的字段被数据层合法丢弃(静态 readonly #2948 / 条件 readonlyWhen #3042)。数据层的 warn 只落在服务端 logger,不进流程运行的步骤日志 —— 作者在 run trace 里只看到一条 3ms 的 success,这正是 #3356 里审批流 stage 回写整链失效被掩盖的原因。

方案拍板

采用 issue 中的**方向 1(引擎结构化回传)**的回调变体,未选方向 3(日志路由):日志路由需要把 per-run logger 一路透传进引擎与剥离函数,侵入更深,且节点只能拿到非结构化文本、拿不到字段名列表。

调研中确认的两个范围收窄:

  • FLS 写门已非静默:plugin-security 的 detectForbiddenWrites 现在直接抛 PermissionDeniedError(fail-closed),会表现为 success: false,不属于本单的静默剥离;
  • insert 不剥离 readonly(INSERT 豁免是既有语义,FLS 抛错),所以 create_record 今天不会产生 warning —— 但按 issue 要求对称接线,未来 insert 若新增静默剥离会自动浮现,不会重新变哑。

实现

spec(契约,@objectstack/spec)

  • data/data-engine.zod.ts:新增 DroppedFieldsEventSchema —— { object, fields, reason: 'readonly' | 'readonly_when' },Zod-first、可发布进 json-schema manifest(已随构建更新);
  • contracts/data-engine.ts:新增 WriteObservabilityOptions(onFieldsDropped 监听器),以交叉类型挂在 IDataEngine.insert/update 的 options 形参上。刻意不进可序列化的 Zod options schema:函数在 JSON Schema 不可表示(会触发 manifest ratchet 失败),也不可能跨 RPC(Virtual Data Engine)边界 —— 这是进程内通道,与 IDataEngine 本身同层(TS 契约层);
  • packages/core 的镜像契约同步(该文件当前无引用方,仅保持一致)。

objectql(engine.update())

  • 在全部 4 个剥离点(单 id / bulk × readonly / readonlyWhen)之后,对比剥离前后键集并回调 onFieldsDropped。对比是精确的:剥离函数在无丢弃时返回同一引用,有丢弃时返回浅拷贝;
  • 监听器抛错不影响写入(catch + warn);
  • system 上下文跳过 readonly 剥离、因此不产生事件 —— 行为与之前一致;
  • insert() 出于签名对称接受该 option,但今天不触发(见上)。

service-automation

  • NodeExecutionResult / StepLogEntry 新增 advisory 的 warnings?: string[],executeNode 在成功/失败步骤条目上透传,随 run history 持久化(compactStepLogForHistory 展开透传,无需改动);
  • update_record / create_record:每个剥离事件生成一条 warning(点名字段与原因,readonlyWhen 措辞覆盖 bulk 的「≥1 matched row」语义),并在 output 暴露结构化的 droppedFields(下游节点可读 {<nodeId>.droppedFields});
  • success 语义不变 —— 剥离依旧是合法语义,只是不再沉默。

测试

  • packages/objectql/src/engine.test.ts 新增 6 例:单 id readonly / 单 id readonlyWhen / bulk readonlyWhen / system 豁免不报 / 无丢弃不报 / 监听器抛错不破坏写入;
  • packages/services/service-automation/src/builtin/crud-dropped-fields.test.ts 新增 5 例:步骤 warning(成功状态保持)、droppedFields 输出变量、无剥离无 warning、create_record 对称接线;
  • 全量回归:spec 6818 ✓ / objectql 1042 ✓ / service-automation 347 ✓ / core 386 ✓;json-schema.manifest.json 与 api-surface.json 已随构建脚本再生(纯增量)。

含 changeset(spec / objectql / service-automation 各 minor)与 content/docs/kernel/contracts/data-engine.mdx 的契约文档更新。

🤖 Generated with Claude Code

https://claude.ai/code/session_01HaofCZbsPTHE2oJedvKd77


Generated by Claude Code

…ings (#3407)

update_record reported an unconditional success even when the data layer
legally stripped the requested write fields (static readonly #2948,
conditional readonlyWhen #3042) — the only trace was a server-side logger
warn, invisible in the flow run trace, which is how #3356's approval stage
write-backs failed behind a clean 3ms success.

- spec: new DroppedFieldsEventSchema ({object, fields, reason}) in
  data/data-engine.zod.ts (Zod-first, JSON-representable, published to the
  schema manifest) + a WriteObservabilityOptions mixin (onFieldsDropped
  listener) on IDataEngine.insert/update option params in
  contracts/data-engine.ts. The listener is TS-contract-level and
  in-process only — a function is unrepresentable in JSON Schema and never
  crosses the RPC boundary. Core's mirror contract kept in sync.
- objectql: engine.update() reports each strip pass's dropped keys +
  reason through options.onFieldsDropped at all four strip sites
  (single-id + bulk x readonly + readonly_when), diffing before/after key
  sets (exact: strips return the same reference when nothing dropped). A
  throwing listener never breaks the write. System-context writes skip the
  readonly strip and report nothing, as before. insert() accepts the
  option for signature symmetry but strips nothing today (INSERT is
  readonly-exempt; FLS write denial throws).
- service-automation: NodeExecutionResult and StepLogEntry gain advisory
  warnings?: string[] (persisted through run history; success semantics
  unchanged). update_record / create_record attach one warning per strip
  event naming the dropped fields and expose a structured droppedFields
  output ({<nodeId>.droppedFields}) for downstream nodes.

Design per issue #3407 direction 1 (engine structured feedback) — chosen
over run-scoped logger routing (direction 3): shallower intrusion, and the
node gets a structured field list instead of log text. FLS is not wired:
its write gate now throws (fail-closed) instead of stripping, so it is no
longer a silent-drop case.

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

vercel Bot commented Jul 22, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Jul 24, 2026 11:25am

Request Review

Three rerun_failed_jobs rounds on the runs queued during the 2026-07-22
GitHub-hosted-runner outage all failed in seconds with no logs (HTTP 404),
including after GitHub resolved the incident at 22:09 UTC. Fresh push to
mint fresh workflow runs instead of re-running the poisoned run records.

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

Copy link
Copy Markdown
Contributor Author

CI 红灯是仓库级 Actions 故障,与本 PR 代码无关 — 诊断如下:

  • 本仓所有分支的所有 workflow run(含其他 PR 分支 claude/field-value-shape-contract-1z4pil、claude/inverse-drift-undeclared-props-5qdonq)从 20:59 UTC 起全部在 3-4 秒内 failure,job 日志 HTTP 404(runner 从未真正启动),check run output 为空;
  • 时间窗与 GitHub 官方事故吻合(githubstatus:GitHub-hosted runners 降级,20:47 确认、22:09 宣布解决),但 22:11 UTC 空提交触发的全新 run 依旧秒死 — 属残余效应或组织级问题(若持续,建议查一下 org 的 Actions 计费/spending limit);
  • 已重踢 4 轮(3 次 rerun_failed_jobs + 1 次空提交新 run)均无效,停止无效重试。

本地全量测试是绿的(spec 6818 / objectql 1042 / service-automation 347 / core 386)。我会继续定时自检,GitHub 恢复后自动重踢并汇报。


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/core, @objectstack/objectql, packages/services, @objectstack/spec.

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

  • content/docs/ai/actions-as-tools.mdx (via @objectstack/core)
  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/knowledge-rag.mdx (via @objectstack/core)
  • content/docs/ai/natural-language-queries.mdx (via @objectstack/core)
  • 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 packages/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/core, packages/services, @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/core, 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/migration-from-objectql.mdx (via @objectstack/core, @objectstack/objectql)
  • content/docs/deployment/troubleshooting.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/cli.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/validating-metadata.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/core, @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/audit-service.mdx (via packages/services)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx (via @objectstack/core)
  • content/docs/kernel/runtime-services/index.mdx (via packages/services, packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/settings-service.mdx (via packages/services)
  • 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/core, @objectstack/objectql, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/core, @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/core, @objectstack/objectql)
  • content/docs/permissions/authorization.mdx (via packages/core, @objectstack/spec)
  • 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/anatomy.mdx (via @objectstack/core)
  • content/docs/plugins/development.mdx (via @objectstack/core, @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/core, @objectstack/objectql, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/core, @objectstack/objectql, packages/services, @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/core, @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/services, @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/core, @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/core, @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/core, @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 @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/core, @objectstack/objectql, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/core, @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v15.mdx (via @objectstack/core)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/objectql, @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.

@os-zhuang
os-zhuang marked this pull request as ready for review July 24, 2026 11:43

Copy link
Copy Markdown
Contributor Author

✅ CI 全绿,已转 ready for review。

GitHub Actions 恢复(7/24 ~11:20 UTC)后:

  • 恢复后的首轮真实 CI 暴露一个问题:check:docs 发现 AUTO-GEN 的 content/docs/references/data/data-engine.mdx 未随 DroppedFieldsEventSchema 再生 — 已在 dae0221 修复(gen:schema && gen:docs 再生,纯增量 +17 行);
  • 当前 head(dae0221)16 个 check 全部通过:CI(Build/Test Core、Generated Artifacts、Dogfood Gate)、Lint & Type Check、CodeQL、Changeset/Size/Label、Spec liveness、Docs Drift、Vercel;
  • 此前两天的整片红灯确认为 GitHub-hosted runner 故障(全仓所有分支秒败、零日志),与本 PR 无关,不再赘述。

CI 已无阻塞,可以 review/merge。


Generated by Claude Code

@os-zhuang
os-zhuang merged commit b95577a into main Jul 24, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/update-record-silent-field-strip-l77rag branch July 24, 2026 12:12
os-zhuang added a commit that referenced this pull request Jul 24, 2026
…#3431) (#3448)

Wire the engine's onFieldsDropped strip-observability channel (#3413) through the DataProtocol and REST write path. updateData collects the engine's readonly/readonlyWhen strips; createData surfaces the #3043 static-readonly ingress strip via a payload diff. Both attach an optional droppedFields to the response. spec: Update/CreateDataResponseSchema gain optional droppedFields (backward-compatible). rest: PATCH/POST echo drops as the X-ObjectStack-Dropped-Fields header and keep the structured list on the body; status/success unchanged.

Deferred to a follow-up: bulk (updateManyData/createManyData/batchData) + GraphQL wiring, typed @objectstack/client warnings, CORS exposeHeaders.
os-zhuang added a commit that referenced this pull request Jul 24, 2026
…design time (#3425) (#3465)

A flow `update_record` node that writes a field the target object declares
`readonly: true`, under the default `runAs: 'user'`, is a silent no-op: the
objectql engine strips static-`readonly` fields from a non-system UPDATE
payload (#2948) so the write never lands, while the step still reports success.
#3407/#3413 surfaced the strip at run time; this shifts discovery left to
`os validate` / `os build`.

- New `@objectstack/lint` rule `validateReadonlyFlowWrites`: static readonly +
  literal field under runAs!=='system' → error (gates); readonlyWhen → warning.
  Skips create_record (INSERT is engine-exempt), runAs:'system' flows, templated
  object names, and non-literal fields maps to stay false-positive-free.
- Wired into `os validate` and `os compile`/`os build` (mirrors the security
  posture gate). Verified: reds on a violating app-crm flow, clean on all
  example apps.
- Documents the formal contract in the objectstack-data / -automation skills:
  readonly governs the user/API surface; system writers (runAs:'system', hooks,
  seeds) maintain it.

Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
baozhoutao added a commit that referenced this pull request Jul 28, 2026
…y` is enforced (#3835)

`POST /batch` called `ql.insert` directly, and the engine's INSERT path is
static-`readonly`-exempt by design (#3413) — the strip that stops a non-system
caller from seeding a read-only column lives at the protocol's create ingress
(#3043). So the same forged value was dropped on `POST /data/:object` and
written through on `/batch`: one rule, two answers.

Create ops now go through `p.createData`, the ingress itself, rather than a
second copy of the strip at the REST layer. One ingress means a future change to
its policy covers the batch for free, and the carve-outs it already encodes stay
intact — the platform-object exemption (a `sys_`/`managedBy` object's own guard
must REJECT a forged value, not silently swallow it) and the `isSystem`
exemption. `trxCtx` is passed as the context, so the insert still joins the batch
transaction and `$ref` resolution is unaffected; the ingress's `droppedFields`
fold into the batch's per-op list.

Update ops are untouched — the engine enforces both strips on its update path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
os-zhuang added a commit that referenced this pull request Jul 28, 2026
…cy, dropped fields, and the batch create ingress (#3794, #3835) (#3834)

* fix(approvals,rest): surface the node's lock policy and the batch's dropped fields (#3794)

An approval flow reported record writability wrong in both directions: what the
user could change said "locked", and what they couldn't said "updated
successfully". Both halves were missing signal, not wrong behaviour — the server
did the right thing and told nobody.

`rowFromRequest` now emits `locks_record`, read from the same `node_config_json`
snapshot the `beforeUpdate` lock hook reads, with the same default-true. A client
could previously only see "a request is pending" and had to guess whether that
locked the record; the Console guessed "locked" every time, which hides the whole
point of a `lockRecord: false` node.

`POST /batch` (cross-object transactional batch) never wired `onFieldsDropped`,
so the one write path the Console's master-detail record form takes was also the
one path with no write-observability — a `readonlyWhen` strip there was
completely silent. It now collects per-op events and returns them as a top-level
`droppedFields` list tagged with each operation's index; omitted when empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(spec): regenerate the batch reference for CrossObjectBatchDroppedFields (#3794)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(spec): refresh the API-surface snapshot for CrossObjectBatchDroppedFields (#3794)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(rest): route batch creates through the create ingress so `readonly` is enforced (#3835)

`POST /batch` called `ql.insert` directly, and the engine's INSERT path is
static-`readonly`-exempt by design (#3413) — the strip that stops a non-system
caller from seeding a read-only column lives at the protocol's create ingress
(#3043). So the same forged value was dropped on `POST /data/:object` and
written through on `/batch`: one rule, two answers.

Create ops now go through `p.createData`, the ingress itself, rather than a
second copy of the strip at the REST layer. One ingress means a future change to
its policy covers the batch for free, and the carve-outs it already encodes stay
intact — the platform-object exemption (a `sys_`/`managedBy` object's own guard
must REJECT a forged value, not silently swallow it) and the `isSystem`
exemption. `trxCtx` is passed as the context, so the insert still joins the batch
transaction and `$ref` resolution is unaffected; the ingress's `droppedFields`
fold into the batch's per-op list.

Update ops are untouched — the engine enforces both strips on its update path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 6, 2026
) (objectstack-ai#5627)

* fix(objectql): treat `autonumber` as implicitly readonly on the write path (objectstack-ai#5503)

A non-system REST caller could POST an explicit record number (bypassing the
sequence) and PATCH an existing one (forging a business identifier). The engine
already documented that "the runtime owns the value, not the client" — and both
record validators exempt a `required` autonumber because of it — but nothing on
the write path enforced it. Author-declared `readonly: true` fields were already
stripped (objectstack-ai#2948 / objectstack-ai#3043); `autonumber` carries no such flag, so the strip loop
walked straight past it. Same defect family as objectstack-ai#4447 (`created_at`).

- UPDATE: `stripReadonlyFields` now reads two sources of "read-only" at equal
  rank — the author-declared flag and a runtime-owned field TYPE
  (`isRuntimeOwnedField`, today exactly `autonumber`). Single-id and `multi`
  bulk updates share this one strip site, so both are covered.
- INSERT: a narrower `stripRuntimeOwnedFields` runs in the engine before
  `applyAutonumbers`. It deliberately does NOT take over the author-declared
  `readonly` insert strip, which stays at the DataProtocol ingress per objectstack-ai#3413 /
  objectstack-ai#3043 (a create may legitimately seed read-only columns, and trusted internal
  writers call `engine.insert` directly).

Stripping in the engine, before dispatch, is what makes this driver-agnostic:
a driver advertising `supports.autonumber` receives a row with no caller value,
so its persistent sequence always wins — no driver changed. The tests assert
the driver-facing payload rather than patching a driver.

Exemptions keep their existing semantics: `isSystem` writes skip the pass, a
`preserveAudit` historical import may still reinstate legacy record numbers
(objectstack-ai#3493 whitelist — `autonumber` is an author-declared business field), and only
CALLER-supplied keys are candidates, so hook stamps survive.

The strip is silent, so it is reported: the insert path's `onFieldsDropped`
(objectstack-ai#3407) is wired at the new strip site as its standing note required, reusing
the existing `readonly` reason code rather than forking the spec vocabulary.
`createManyData` and `insertManyData` forward the listener too; the latter keeps
row precision by attributing each dropped name to the rows that supplied it.

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

* test(objectql): drop two `as any` erasures on ordinary engine reads (objectstack-ai#4918 ratchet)

`check:query-options-erasure` reported the test surface growing 267 -> 269: the
objectstack-ai#5503 test file passed `{ where: { id } } as any` to `findOne` and `{} as any`
to `find`. Neither is deliberately off-contract — both values are plain
`EngineQueryOptions` that the signatures already infer — so the gate's FIRST
remedy applies (drop the assertion), not the `as unknown as EngineQueryOptions`
spelling reserved for tests whose subject IS off-contract input.

The baseline is untouched: the count returns to its 267 ceiling on its own.

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

* docs(changeset): record the strictReadonlyWrites superposition (objectstack-ai#5503 x objectstack-ai#5126)

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 17, 2026
…ores it (objectstack-ai#7064) (objectstack-ai#7109)

The closing paragraph of the strictReadonlyWrites TSDoc still asserted the
option is inert on insert ('INSERT ignores it ... insert is exempt from both
strips, so there is nothing to refuse') — true when objectstack-ai#5126 shipped, false
since objectstack-ai#5503 wired engine.insert to REFUSE a payload carrying a runtime-owned
value under strict, throwing ReadonlyFieldRejectedError (operation: 'insert')
and writing nothing.

Verified by execution against origin/main before rewriting (three-probe
matrix): strict insert with an autonumber value throws
ERR_READONLY_FIELD_REJECTED / operation 'insert' with nothing written; the
same insert without strict silently strips and fires onFieldsDropped with
reason 'readonly'; an author-declared readonly field on insert stays exempt
at this seam (objectstack-ai#3413) even under strict.

Prose only: the interface header and the Semantics arm enumeration gain the
runtime-owned strip (objectstack-ai#5503), and the INSERT paragraph now states what insert
refuses (runtime-owned values only), names the engine-level exempt writers
the error message names (isSystem, preserveAudit objectstack-ai#3493), and pins the layer
boundary against the DataProtocol ingress policy (objectstack-ai#3043/objectstack-ai#6640) so the two
never read as one rule. No key/type/behaviour change; all 10 spec generated
artifacts verified unchanged.


Claude-Session: https://claude.ai/code/session_018ffcE95NaMJcL9XJ9VDYgk

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 17, 2026
…tion, and reap the retired ciphertext (objectstack-ai#8030) (objectstack-ai#8063)

A second PUT of a new value for an encrypted setting key answered 200 with a
correctly redacted body, advanced `updated_at`, wrote an audit row and inserted
a genuinely new `sys_secret` row holding the new plaintext — and left
`sys_setting.value_enc` pointing at the FIRST handle. The effective secret never
changed, so a leaked credential survived the rotation meant to retire it, with
every visible signal saying the rotation had landed. The first write of any
secret was correct, so it was invisible until the second.

Cause: `sys_setting.value_enc` (and `updated_by`) are declared `readonly: true`,
and the engine strips author-declared read-only columns from a NON-system
caller's UPDATE payload (`stripReadonlyFields`, gated on
`!opCtx.context?.isSystem`). `SettingsService.upsertRow` persisted through a
plain, un-elevated `engine.update`, so the handle could never be repointed. The
INSERT path is deliberately exempt from that strip (objectstack-ai#3413) — which is exactly
why the first write landed and every later one did not.

Fix, on the service side only:

- `upsertRow` performs its UPDATE as a system write. `SettingsService` is a
  privileged writer — the manifest capability gate, the env/upper-scope lock
  pre-flight and `validatePatch` have all run by then, and these are columns it
  owns rather than ones a caller forged. Same posture, same reason, as
  `ObjectQL.recomputeSummaries`' elevation (objectstack-ai#7673).
- `SettingsEngine.update` gains a `context` member and the `IDataEngine`
  adapter forwards it on BOTH branches. The settings row write takes the
  `multi` one (its `where` is the composite key, never an `id`), so the by-id
  branch is the half that would rot unnoticed.
- `value_enc` STAYS `readonly: true`. The elevation is scoped to this one
  write, so an external caller reaching `sys_setting` through the data layer
  still cannot repoint a secret handle. A test fails if that flag is removed.

Orphans are reaped rather than accepted: the `sys_secret` row a rotated-away
handle named is deleted once the repoint has committed. An orphan is a
decryptable copy of the credential the admin just retired, one more per
rotation. The store's `delete` is optional and the call is best-effort — the new
secret is already in force by then, and a failed cleanup is reported, never
raised.

Tests boot a real ObjectQL over the real `SysSetting` / `SysSecret` schemas and
drive the real service through the real adapter, so the verdict is about the
engine's actual strip rule rather than about a fake written to match the fix.
`vitest.config.ts` aliases `@objectstack/objectql` and `@objectstack/core` to
source, per `check-test-source-alias`.

Fixes objectstack-ai#8030


Claude-Session: https://claude.ai/code/session_01AFizXytCQYaGCNvewL7TaE

Co-authored-by: Claude <noreply@anthropic.com>
os-project-manager pushed a commit that referenced this pull request Aug 31, 2026
)

A hook's `ctx.api` is a ScopedContext over the TRIGGERING operation's
execution context, so `ctx.api.object('x').update({ readonlyField })`
reaches the engine as an ordinary non-system caller and the update path
strips the key. The call returns success and the column stays null - a
failure only an end-to-end read-back detects. This completes the hook
side of the flow-side gate that shipped as `flow-update-readonly-field`.

The rule keys on the write CHANNEL, not on the field: a beforeInsert /
beforeUpdate body stamping `ctx.input.<field> = ...` writes a server
value that survives the strip (#5591) and is never flagged - `readonly`
plus a before-hook is a correct and widely used pairing. Also skipped,
each for a stated reason: `ctx.api.sudo()` chains (elevated, the
intended channel), insert/create (INSERT is engine-exempt, #3043/#3413),
dynamic object names, non-literal payloads, objects or fields this stack
does not declare, and `id` in an update payload (the row address, #8141).

- `hook-api-update-readonly-field` - error
- `hook-api-update-readonly-when-field` - warning (per-record state)

Wired through REFERENCE_INTEGRITY_RULES so it runs on `os validate`,
`os lint` and `os compile` at once. `buildReadonlyIndex` is now shared
from the flow rule rather than copied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 1, 2026
…ectstack-ai#13771)

* feat(lint): gate a hook body's ctx.api write to a readonly field (objectstack-ai#13653)

A hook's `ctx.api` is a ScopedContext over the TRIGGERING operation's
execution context, so `ctx.api.object('x').update({ readonlyField })`
reaches the engine as an ordinary non-system caller and the update path
strips the key. The call returns success and the column stays null - a
failure only an end-to-end read-back detects. This completes the hook
side of the flow-side gate that shipped as `flow-update-readonly-field`.

The rule keys on the write CHANNEL, not on the field: a beforeInsert /
beforeUpdate body stamping `ctx.input.<field> = ...` writes a server
value that survives the strip (objectstack-ai#5591) and is never flagged - `readonly`
plus a before-hook is a correct and widely used pairing. Also skipped,
each for a stated reason: `ctx.api.sudo()` chains (elevated, the
intended channel), insert/create (INSERT is engine-exempt, objectstack-ai#3043/objectstack-ai#3413),
dynamic object names, non-literal payloads, objects or fields this stack
does not declare, and `id` in an update payload (the row address, objectstack-ai#8141).

- `hook-api-update-readonly-field` - error
- `hook-api-update-readonly-when-field` - warning (per-record state)

Wired through REFERENCE_INTEGRITY_RULES so it runs on `os validate`,
`os lint` and `os compile` at once. `buildReadonlyIndex` is now shared
from the flow rule rather than copied.

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

* fix(lint): keep tracker ids out of the new rule's author-facing strings

`check:doc-authoring` gates on this: a finding's `message`/`hint` reaches
authors, operators and generated surfaces, none of whom can resolve
`#NNNN`. The four ids (objectstack-ai#2948, objectstack-ai#3042, objectstack-ai#5591, objectstack-ai#9107) move to adjacent `//`
comments, where the reader who CAN resolve them already is.

Maintainer ruling 2026-08-12: 「处理 issue 时犯的错应该总结成经验,保留 issue id没有意义」

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

* docs(automation): correct the "hook write checks are advisory" claims this rule falsifies

Three statements went stale the moment a hook-body write check began to gate,
and none of them was reachable by the docs drift check: `packages/lint/src/
index.ts` yields no anchor, and a page that states a rule by its INPUTS shares
no token with a diff that changed the EMITTER.

- hooks.mdx listed "readonly targets" as a flow-only `os validate` check and
  said a hook body's write set is checked "only as an advisory warning".
- hook-bodies.mdx called the whole write side "advisory and literal-only".

Existence stays advisory for its own reason (the answer can depend on a package
the build cannot see); writability gates because both halves of that judgement
are declared in the stack being checked. The pages now say which is which.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 9, 2026
…n non-published prose sites (objectstack-ai#17020)

* docs(engine): state the ruled readonly-on-INSERT contract at the seven non-published prose sites

The 2026-09-03 maintainer ruling (option C, objectstack-ai#14147) put the static `readonly`
strip inside `engine.insert` under the same `isSystem` gate as `engine.update`
and deleted the metadata-protocol ingress copy. PR objectstack-ai#15395 fixed every site that
reached a published artefact; the 2026-09-05 E3 ruling carded the rest.

These seven prose sites still stated the superseded contract in the present
tense — that a non-system INSERT is exempt from the static strip, or that the
strip lives at the DataProtocol create ingress. Each is rewritten to the ruled
state, following the R3/R4 corrections in rest-server.ts, settings-service.ts
and crud-nodes.ts:

- objectql/src/readonly-strict-errors.ts — the module docblock's account of
  what the refusal union contains on INSERT (the `operation` TSDoc 190 lines
  below was already corrected in R4 and disagreed with it).
- service-settings settings-secret-rotation.test.ts (header + a helper) and
  sys-secret-orphan-report.test.ts (a helper) — sys_setting's INSERT is still
  outside the strip, but by the platform-object carve-out in
  staticReadonlyInsertSubject (objectstack-ai#15719), not by objectstack-ai#3413.
- rest/src/rest-batch-endpoint.test.ts — the create-ingress-parity comment; the
  routing it pins is unchanged, only the stated reason was superseded.
- service-automation crud-dropped-fields.test.ts — the "future insert-side
  strip" is live.
- spec/scripts/liveness/proof-registry.mts — the provenance comment on the
  readonly-static-write proof, which covers both write paths.

Comments only: no behaviour change, no test deleted, skipped or reassigned, no
exported symbol and no published payload key touched.

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

* chore: add the patch changeset the readonly-INSERT prose diff was measured to need

Measured, not assumed: the five touched packages were built before and after
the edit and every dist file hashed, with `npm pack --dry-run` read for what
ships. Two released packages publish changed bytes — @objectstack/objectql
(4 sourcemaps) and @objectstack/rest (2 sourcemaps, because its bundle inlines
objectql's source and the comment moves its line numbers). Every emitted .js /
.mjs / .cjs and every .d.ts / .d.mts / .d.cts is byte-identical.

skip-changeset is for a diff that publishes nothing from any released package,
so it does not apply here.

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

---------

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

This branch was successfully deployed

1 active deployment
Preview — dae02214 Deployed Jul 24, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

service-automation: update_record reports success when written fields are silently stripped — no observability for dropped writes (split from #3356)

2 participants