…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
Fixes #4669
症状与因果
sys_permission_set每一行都带一个active存储列。ADR-0094 D4 的 backfill 走permissionSetBodyFromRow(row)把整行转成 metadata body,active跟着进去;#4001 之后PermissionSetSchema是.strict(),于是每一次saveMetaItem都抛失败被
catch成一条warn、backfilledIntoMetadata不加一 —— 测试全绿,没有任何自动信号,一条整体停摆的投影路径就这样过了一个发布周期。归属判定:
active是行状态,不是声明按 PM 裁定走投影侧挑键(正文方案 1),
packages/spec/**零改动。理由在它的全部消费面上:表列、highlightFields、两个 list view 的 filter、两个启停动作的bodyExtra—— 全是记录的运行时开关,不是作者声明的能力边界。把它加进 spec 等于把状态提升成契约,方向不对。改了什么
1. 白名单从 schema 派生,不是手抄
permissionSetBodyFromRow()和mergeRowPatchIntoBody()现在都过一道pickSpecDeclaredKeys(),键表来自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/contracts的Logger.error(message, error?, meta?);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(未认领)。验证
测试确实能抓到这个 bug:mock protocol 的
saveMetaItem现在跑真的PermissionSetSchema,与protocol.ts的resolveOverlaySchema一模一样的 422 信封。把缺陷放回去(permissionSetBodyFromRow重新带上active)再跑:真实 stack 端到端(showcase,真 kernel + 真 metadata protocol,临时 dogfood 用例跑完即删):往
sys_permission_set插一条带active列、没有 metadata 定义的遗留行,再跑 boot reconcile ——计数增长、正文那条 WARN 不再出现、落库的 body 里没有
active、记录自己的active列纹丝不动。既有的两个 permission 相关 dogfood 用例也全绿(showcase-permission-projection+two-doors-permission,12/12)。约束核对
packages/spec/**零改动(白名单用的是运行时可及的 schema 导出)content/docs/releases/未碰;cloud 仓未碰.changeset/permission-backfill-row-state-columns.md行为变化(供 review 判断)
active的数据门 PATCH 现在放行给驱动而不是改写成 metadata 写入。对一个还没有 metadata 定义的遗留行,这意味着它不再顺手被 author 进元数据 —— 启动时的 reconcile 仍会 backfill 它。upsertEnvPermissionSet创建记录时active恒为true(与包 seeder 和字段defaultValue一致),不再从 body 取。ProjectionReconcileOutcome新增必填字段backfillFailed(该类型只在本包内被消费)。🤖 Generated with Claude Code
https://claude.ai/code/session_015Br2xsJsczFsTR9bvbh2Ny
Generated by Claude Code