Skip to content

fix(security): permission-set 投影只写 spec 认的键;失败的 backfill 变响亮 (#4669) - #4755

Merged
os-zhuang merged 4 commits into
mainfrom
claude/issue-4669-permission-backfill-strict-spec
Aug 3, 2026
Merged

fix(security): permission-set 投影只写 spec 认的键;失败的 backfill 变响亮 (#4669)#4755
os-zhuang merged 4 commits into
mainfrom
claude/issue-4669-permission-backfill-strict-spec

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #4669

症状与因果

sys_permission_set 每一行都带一个 active 存储列。ADR-0094 D4 的 backfill 走 permissionSetBodyFromRow(row) 把整行转成 metadata body,active 跟着进去;#4001 之后 PermissionSetSchema.strict(),于是每一次 saveMetaItem 都抛

[invalid_metadata] permission/d8_qc_user failed spec validation: :
Unrecognized key(s) on this permission set: `active`.

失败被 catch 成一条 warnbackfilledIntoMetadata 不加一 —— 测试全绿,没有任何自动信号,一条整体停摆的投影路径就这样过了一个发布周期。

归属判定:active 是行状态,不是声明

按 PM 裁定走投影侧挑键(正文方案 1),packages/spec/** 零改动。理由在它的全部消费面上:表列、highlightFields、两个 list view 的 filter、两个启停动作的 bodyExtra —— 全是记录的运行时开关,不是作者声明的能力边界。把它加进 spec 等于把状态提升成契约,方向不对。

改了什么

1. 白名单从 schema 派生,不是手抄

permissionSetBodyFromRow()mergeRowPatchIntoBody() 现在都过一道 pickSpecDeclaredKeys(),键表来自 PermissionSetSchema.shape 本身:

let cachedSpecBodyKeys: ReadonlySet< string > | null = null;
export function permissionSpecBodyKeys(): ReadonlySet< string > {
  return (cachedSpecBodyKeys ??= new Set(Object.keys(PermissionSetSchema.shape ?? {})));
}

手抄一份字符串数组会在 spec 加键的那天静默漏掉它 —— 那正是本 issue 的病,只是换了一层。首次使用才解析,不在 module load 时把 lazySchema 代理拆开。

覆盖面比 backfill 一处大:#4001 之前已经落库、body 里仍带 active 的历史 overlay 行(data at rest)也在同一个闸口被滤掉,所以那些 set 的数据门编辑不再 422。

2. 两个启停动作行为不变(并且顺带不再造假 overlay)

只含行状态的 PATCH(bodyExtra: { active: … } 正是这个形状)不再被改写成 metadata 写入,而是 return next() 交给驱动做列写入 —— 保留 history / updated_at / FLS 等一切正常语义,并且不会再给一个包自带的 permission set 平白造出一条 "customization" overlay。INSERT(Clone 动作会带 active)在投影之后把列补上。

反方向也钉住了:投影通道不再从 body 读 active(upsertEnvPermissionSet / recordDiffersFromBody)。否则一条陈旧的历史 body 会在下一次投影时把管理员刚停用的 set 重新打开。

3. 第二半:失败变响亮(#4632 / AGENTS.md「Degradation log levels」)

这是规则里的第二类降级 —— 记录照常列出、evaluator 照常从表里解析,看起来一切正常,而定义根本不在权威存储里。现在:

  • 级别 error,签名对齐 @objectstack/spec/contractsLogger.error(message, error?, meta?);
  • 文案含后果(重新 provision 不会重建它、每次启动都同样失败)与修复动作;
  • 「说一次」—— 第一次失败给完整那条,其余计数;
  • 新增 ProjectionReconcileOutcome.backfillFailed,降级进结果,不只进日志;汇总行在有失败时也走 error(一条 info "reconciled" 盖在失败的 backfill 上,正是这条规则要消灭的安慰性半真话)。

同文件 restore 分支那处同类 warn(重新 author 失败)一并对齐 —— 同一函数族、同一类降级,只修一处、留下三行外的孪生兄弟本身就是个 finding。若认为越界,可以单独摘出。

闸门局限(照 PM 说明)

check:durability-log-level 的词表(DURABILITY_CRITICAL_CALLEES)覆盖不到这类投影写入 —— 它不认 saveMetaItem。本 PR 跑 node scripts/check-durability-degradation-log-level.mjs 是绿的,但那是因为闸门看不见,不是因为它检查过。

我实测了把 saveMetaItem 加进词表的影响:8 处违规,3 个包 4 个文件(runtime/domains/packages.ts ×3、rest/rest-server.ts ×2、metadata-protocol/protocol.ts ×2、runtime/domains/meta.ts ×1),全部是 catch 里连日志都没有的完全静默。逐一判定 + 词表条目必须同一个 PR,那是一次独立清账,已单独立: #4754(未认领)。

验证

$ pnpm --filter @objectstack/plugin-security test --maxWorkers=2
 Test Files  33 passed (33)
      Tests  718 passed (718)

$ pnpm --filter @objectstack/plugin-security typecheck
> tsc --noEmit        (无输出 = 通过)

$ node scripts/check-durability-degradation-log-level.mjs
✓ durability-degradation log levels: 8 durability-critical catch seam(s), all loud or rethrowing (1 baselined).

测试确实能抓到这个 bug:mock protocol 的 saveMetaItem 现在跑真的 PermissionSetSchema,与 protocol.tsresolveOverlaySchema 一模一样的 422 信封。把缺陷放回去(permissionSetBodyFromRow 重新带上 active)再跑:

 × drops `active` and every other storage column from the projected body
 × every key the projection emits is one the spec ACCEPTS (parsed by the real schema)
 × filters a body STORED before #4001 (data at rest can still carry `active`)
 × [#4669] INSERT honours an explicit `active` on the record (Clone action sends one)
 × backfills a legacy data-door-only record into the metadata store ONCE
 × [#4669] a row carrying the `active` STORAGE COLUMN backfills instead of failing spec validation
Error: [invalid_metadata] permission/support_agent failed spec validation: <root>:
  Unrecognized key(s) on this permission set: `active`. …
 Tests  6 failed | 38 passed (44)

真实 stack 端到端(showcase,真 kernel + 真 metadata protocol,临时 dogfood 用例跑完即删):往 sys_permission_set 插一条带 active 列、没有 metadata 定义的遗留行,再跑 boot reconcile ——

OUTCOME {"projectedFromMetadata":0,"backfilledIntoMetadata":1,"driftHealed":0,"backfillFailed":0}
LOGS
INFO [security] sys_permission_set projection reconciled (ADR-0094 D4) {…,"backfilledIntoMetadata":1,"backfillFailed":0}

计数增长、正文那条 WARN 不再出现、落库的 body 里没有 active、记录自己的 active 列纹丝不动。既有的两个 permission 相关 dogfood 用例也全绿(showcase-permission-projection + two-doors-permission,12/12)。

约束核对

  • packages/spec/** 零改动(白名单用的是运行时可及的 schema 导出)
  • content/docs/releases/ 未碰;cloud 仓未碰
  • changeset 在位:.changeset/permission-backfill-row-state-columns.md

行为变化(供 review 判断)

  1. 只含 active 的数据门 PATCH 现在放行给驱动而不是改写成 metadata 写入。对一个还没有 metadata 定义的遗留行,这意味着它不再顺手被 author 进元数据 —— 启动时的 reconcile 仍会 backfill 它。
  2. upsertEnvPermissionSet 创建记录时 active 恒为 true(与包 seeder 和字段 defaultValue 一致),不再从 body 取。
  3. ProjectionReconcileOutcome 新增必填字段 backfillFailed(该类型只在本包内被消费)。

🤖 Generated with Claude Code

https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny


Generated by Claude Code

…tadata, and make a failed backfill loud (#4669)

`sys_permission_set` carries an `active` STORAGE COLUMN — the on/off switch the
Setup list views filter on and the two lifecycle actions toggle. ADR-0094 D4's
boot backfill turned a whole row into a metadata body, so `active` went with it;
#4001 then sealed `PermissionSetSchema` `.strict()`, and every backfill began
failing with `[invalid_metadata] … Unrecognized key(s) on this permission set:
'active'`. The failure was caught into one `warn` and no counter moved, so a
100%-failing projection path stayed green for a release.

`active` is row state, not a declaration — its entire consumer surface (column,
highlightFields, list-view filters, the actions' `bodyExtra`) is the record's
runtime switch, never a capability boundary an author declared. So the fix is on
the PROJECTION side; `packages/spec` is untouched.

- `permissionSetBodyFromRow()` / `mergeRowPatchIntoBody()` now pass through a
  whitelist DERIVED from `PermissionSetSchema.shape` — not a transcribed string
  list, which would silently drop the next key the spec grows (this defect, one
  layer over). Storage columns never enter a body, and a body STORED before
  #4001 (data at rest can still carry `active`) is filtered at the same choke
  point, so its data-door edits stop 422-ing.
- The activate/deactivate actions keep working: a patch that touches only row
  state is not a definition write and passes through to the driver, so the
  column write keeps its ordinary engine semantics and no bogus "customization"
  overlay is minted on a packaged set. The projector no longer reads `active`
  from a body either — a stale body can no longer re-activate a set an admin
  just switched off.
- A real backfill failure is now loud per AGENTS.md "Degradation log levels"
  (#4632): `error` level with the consequence and the fix, said once at the
  first failure, plus a new `ProjectionReconcileOutcome.backfillFailed` counter
  so the degradation lands in the RESULT and not only in a log line.

Tests: the mock protocol now validates with the REAL `PermissionSetSchema`,
exactly as `saveMetaItem` does — re-introducing the defect fails 6 of them with
the issue's verbatim error.

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

vercel Bot commented Aug 3, 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 3, 2026 2:57am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/l labels Aug 3, 2026
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security.

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

  • content/docs/deployment/cli.mdx (via @objectstack/plugin-security)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/plugin-security)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/access-recipes.mdx (via packages/plugins/plugin-security)
  • content/docs/permissions/authorization.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/explain.mdx (via @objectstack/plugin-security)
  • content/docs/permissions/permissions-matrix.mdx (via packages/plugins/plugin-security)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/plugin-security)
  • content/docs/plugins/index.mdx (via @objectstack/plugin-security)
  • content/docs/plugins/packages.mdx (via @objectstack/plugin-security)
  • content/docs/releases/implementation-status.mdx (via @objectstack/plugin-security)
  • content/docs/ui/audience-based-interfaces.mdx (via packages/plugins/plugin-security)
  • content/docs/ui/dashboards.mdx (via @objectstack/plugin-security)

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 3 commits August 3, 2026 02:31
…ant.test.ts, a 5s timeout in a test #4752 landed after this branch (zero spec files in this diff)

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

Copy link
Copy Markdown
Contributor Author

CI 更新:第一次 Test Core (1/2) 红,与本 diff 无关,重跑后 20/20 全绿

红的是什么:packages/spec/src/cloud/tenant.test.ts 里的
[#4739] TenantPlan(Schema) resolves to the ./cloud declaration everywhere > resolves the export surface: …
—— Error: Test timed out in 5000ms.,不是断言失败,也不是编译错误。

为什么与本 PR 无关:

处置:把最新 main(含 #4752)合进本分支、重建 spec 与依赖、重跑本包验证(718 tests / typecheck 全绿),推送后重跑 CI:20 checks,0 failed

⚠️ 顺带一条给维护者的观察(没有在本 PR 里动它):那不是随机抖动,而是一个贴着默认超时的新用例 —— 3.3s vs 5s 的余量,在负载高的 runner 上会周期性地翻。同一文件的其他用例已经显式放宽了超时,这一个没有。要不要给它单独补一个 testTimeout,属于 #4739/#4752 那条车道的事,这里只作记录。


Generated by Claude Code

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.

permission-set backfill (ADR-0094 D4) 现在 100% 失败:行里的 active 存储列喂进了 #4001 之后严格化的 permission spec

2 participants