Skip to content

feat(compliance): make last_status/last_assessed_at a live nested-write roll-up (#84)#90

Merged
os-zhuang merged 2 commits into
mainfrom
claude/live-rollups-compliance
Jul 19, 2026
Merged

feat(compliance): make last_status/last_assessed_at a live nested-write roll-up (#84)#90
os-zhuang merged 2 commits into
mainfrom
claude/live-rollups-compliance

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

背景

延续 #84 的最后一项、也是唯一一个非聚合跨对象 rollup。compliance_control.last_status / last_assessed_at 之前是手工维护的存储字段:seed 里逐个 control 写死,评估(assessment)落地时没有任何自动重算。字段本来就是为 hook 设计的(last_assessed_at 的 description 一直写着 "set by hook when a related assessment moves to passed/failed/partial"),只是因为 hook 里的嵌套跨对象写入会把 QuickJS 沙箱写崩(framework#1867)才降级成存储字段。该 bug 已修复。

因为这是「取最近一次已完成评估」的非聚合 rollup(不是 sum/count),原生 Field.summary 表达不了 —— 正确工具就是 #84 item 2 说的嵌套写钩子

改动

新增 assessmentRollupHook(compliance_assessment 的 afterInsert/afterUpdate):从该 control 的全部评估里重新推导,把最近一次已完成(passed/partial/failed)评估的 status + assessed_at 写到 control 上(没有已完成评估则写 not_tested / null)。

body-only 沙箱的两个坑(靠对 pinned @objectstack 15.1.1 运行时 boot 实测发现)

  • 钩子沙箱暴露的是 engine-repo facade,单条更新是 update(data, opts)、id 放在 data 里 —— 没有 updateById。钩子照搬引擎自己的写法(id 进 payload + where: { id })。
  • last_assessed_at 是 datetime 字段,要 ISO-8601 字符串、不是 epoch 数字,所以钩子用 new Date(...).toISOString() 归一化(assessed_at 是 date 粒度)。

seed

  • 删掉 6 个已评估 control 上手写的 last_status/last_assessed_at(现由钩子在评估载入时派生)
  • 补两条历史 passed 评估(GDPR Art.32 约 200 天前、HIPAA §164.308(a)(1) 约 40 天前),让这两个 control 的派生结果与原 seed 完全一致
  • 两个从未评估的 control(A.8.16、164.312(a)(1))显式 seed 初始 not_tested(钩子对它们不会触发)

验证(fresh dev server,@objectstack 15.1.1,0 错误,无沙箱崩溃)

  • 8 个 control 的 last_status / last_assessed_at 与改动前的 seed 基线逐一完全一致(与 origin/main boot 对拍验证)
  • 运行时 afterInsert:通过 REST 给一个从未评估的 control POST 一条 passed 评估 → 该 control 实时变成 passed@today
  • 运行时 afterUpdate:把该评估从已完成状态迁走 → control 实时回退到 not_tested

说明

  • 这是本仓第一个真正走 framework#1867 修复路径(hook 嵌套写)的模板,boot 实测确认 15.1.1 运行时不再崩溃。
  • is_overdue_for_review 对「日期陈旧」控件仍返回 None —— 这是模板既有的 formula 行为(origin/main 用 seed 的 cel 值也一样,last_assessed_at 存的是同样的 ISO 串),本 PR 未触及,不是回归。

影响

纯模板改动,不改 framework。至此 #84 五个字段全部处理完:expense/procurement/content 走 summary,compliance 走嵌套写钩子,project 单独说明(见后续)。


Generated by Claude Code

claude added 2 commits July 19, 2026 16:07
…te roll-up (#84)

`compliance_control.last_status` / `last_assessed_at` were hand-maintained
stored fields: the seed set them per control and nothing recomputed them
when an assessment landed. The intended design (the `last_assessed_at`
field description already said "set by hook when a related assessment
moves to passed/failed/partial") was blocked because a nested cross-object
write from a hook crashed the QuickJS sandbox — framework#1867. That is
fixed, so the roll-up is now live.

This is a NON-aggregate roll-up ("most recent completed assessment", not a
sum/count), so a native `summary` field cannot express it — the right tool
is a hook that writes the parent control via `ctx.api`. Add
`assessmentRollupHook` (afterInsert/afterUpdate on compliance_assessment):
it re-derives the control from ALL of its assessments and copies the latest
completed (passed/partial/failed) one's status + assessed_at onto the
control (or `not_tested` / null when none is completed).

Notes for the body-only sandbox (learned by boot-testing against the
pinned @objectstack 15.1.1 runtime):
- The hook sandbox exposes the engine-repo FACADE, whose single-record
  update is `update(data, opts)` with the id in `data` — there is no
  `updateById`. The hook mirrors the engine's own recipe (id in payload +
  `where: { id }`).
- `last_assessed_at` is a datetime field: it wants an ISO-8601 string, not
  an epoch number, so the hook normalises the (date-granular) assessed_at
  through `new Date(...).toISOString()`.

Seed:
- drop the hand-set last_status/last_assessed_at from the 6 assessed
  controls (now derived by the hook as the assessments load)
- add two historical passed assessments (GDPR Art.32 ~200d ago, HIPAA
  §164.308(a)(1) ~40d ago) so those controls' rolled-up status reproduces
  the previous seed exactly
- the two never-assessed controls (A.8.16, 164.312(a)(1)) seed an explicit
  `not_tested` initial state (the hook never fires for them)

Boot-verified against a fresh dev server (@objectstack 15.1.1, 0 errors,
no sandbox crash):
- all 8 controls' last_status / last_assessed_at match the previous seeded
  baseline exactly
- runtime afterInsert: POSTing a passed assessment for a never-assessed
  control flips it to passed@today live
- runtime afterUpdate: transitioning that assessment out of a completed
  state reverts the control to not_tested

hooks/index.ts, compliance_assessment.hook.ts, CHARTER.md: replace the
stale "STORED field / sandbox crashes on nested writes" notes with the
live roll-up description.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZguyAaQbyUpwMZ2gMLaAP
Fix the CI `format:check` (prettier --check) failure on #90 — apply the
repo's Prettier style to the two changed files (no logic change):
- src/objects/compliance_assessment.hook.ts
- src/data/index.ts

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZguyAaQbyUpwMZ2gMLaAP
@os-zhuang
os-zhuang merged commit 3ec18ca into main Jul 19, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants