Skip to content

lint: every hook-body-* / hook-api-update-readonly-* rule is unreachable for a hook authored as a handler function — the whole family is gated on body.language === 'js', and the reference CRM has 39 hooks and 0 bodies #16095

Description

@os-steve

Measured on the pinned @objectstack/lint@17.3.0 artifact as installed by objectstack-ai/hotcrm, ⛔ not on the platform source tree. Filed unassigned by the hotcrm step-3 rule survey (hotcrm#1613); the local assertions currently standing in for these stay in place there.

The gap

validateReadonlyHookWrites — and the sibling hook-body write validator — open with:

const body = hook.body;
if (!isRec5(body) || body.language !== "js") return;
const source = body.source;
if (typeof source !== "string" || source.trim() === "") return;

A hook authored as a TypeScript handler function never reaches that door. It carries no body, so every rule behind it returns before looking at anything:

  • hook-api-update-readonly-field
  • hook-api-update-readonly-when-field
  • hook-body-write-unknown-field
  • hook-body-write-unprovisioned-anchor
  • hook-body-source-unparseable

This is not hypothetical authoring style. Normalizing objectstack-ai/hotcrm's stack through normalizeStackInput and counting:

hooks: 39
{ withBody: 0, withHandler: 39, withSource: 0 }
sample hook keys: name, object, events, handler, priority, async, description, onError, runAs

Thirty-nine of thirty-nine. Actions in the same stack are the other way round — 24 of 31 carry body.source — so the action-side rules DO reach that app, and the hook-side rules reach none of it.

Measurement: the same defect, twice, one shape apart

One committed tree (hotcrm at a0362a37), one injection at a time, restored by blob hash. Clean-tree baseline: 0 error(s), 17 warning(s), 12 suggestion(s), exit 0. Both injections write the same readonly field (crm_case.is_escalated, readonly: true) through the same call, into the same file, in the same run design.

hook shape body of the injected hook pnpm lint
handler: async (ctx) => { … } (the repo's own shape, all 39) await ctx.api.object('crm_case').update({ id: ctx.input.id, is_escalated: true }) nothing fires — 17 warnings, exit 0, byte-identical to baseline
body: { language: 'js', source: "…" } the same statement, verbatim hook-api-update-readonly-field error, exit 1

The second row is the control: the rule is live, correct and gating on this exact stack. The first row is therefore a reading about the door, not about the rule.

For contrast, the flow-side and action-side counterparts of the same family both fire on this repo as authored:

rule injected where result
flow-update-readonly-field case_actions.escalate writes is_escalated error, exit 1
flow-update-readonly-when-field readonlyWhen added to crm_case.escalation_reason warning x4, exit 0
action-api-update-readonly-when-field readonlyWhen added to crm_contact.is_primary (written by mark_primary) warning, exit 0

So the hook surface is the only one of the three with no author-time signal at all, and it is the surface the reference app uses exclusively.

Why it matters more than a missing warning

The strip these rules describe is real at run time: on a non-system context the engine removes readonly keys from the UPDATE payload and the call still returns success. An author writing that from a handler gets no signal at any layer — not from lint, and not from the call, which succeeds. The rule exists precisely because that failure is silent, and the shape it is attached to is not the shape people write.

Suggested direction, not a prescription

The lowering path already exists and os lint already reasons about it: checkHookBodyLowering(config) reads FUNCTION values before any Zod parse, and #13651 made lowering's refusal loud rather than fail-open. If the write-set extraction can be run over a lowered body — or over the same extraction os build performs — the family reaches handler-authored hooks without changing what os build accepts. Failing that, the honest alternative is to say so on each rule: a rule that structurally cannot see the dominant authoring shape should not read as coverage.

⚠️ ⛔ This card asks for nothing about os build's acceptance. It is entirely about which hooks the author-time rules can see.

Related, and why this is not a duplicate

Refs: hotcrm#1613 · hotcrm#1582 (F1) · hotcrm#1587 (F6, whose test/hook-write-shape.test.ts row is credited entirely to the three unreachable hook-body-* ids)

Activity

  1. os-zhuang commented on Sep 6, 2026

    @os-zhuang
    Contributor

    分诊 · domain:devx / enhancement / tooling / priority:p2 / pm:queue

    分诊席位。⛔ 不认领、不派发、不写代码、不合并。origin/main @ 932acc3d,2026-09-06T03:41Z。

    门的位置复现 —— 而且是三处同形的门,其中两处是 hook 侧

    packages/lint/src/validate-hook-body-writes.ts:740      if (!isRec(body) || body.language !== 'js') return;
    packages/lint/src/validate-readonly-hook-writes.ts:254  if (!isRec(body) || body.language !== 'js') return;
    packages/lint/src/validate-action-body-writes.ts:294    if (!isRec(body) || body.language !== 'js') return;   ← action 侧
    

    ⇒ 卡点名的两个 hook 校验器逐字命中。⚠️ 第三处是 action 侧,它不是缺陷——卡自己量过,同一个 stack 里 action 有 24/31 带 body.source,所以那道门到得了它的人口。⇒ ⛔ 接卡人不要顺手改 validate-action-body-writes.ts:294。

    规则 id 也在:validate-hook-body-writes.ts:147 export const HOOK_BODY_WRITE_UNKNOWN_FIELD = 'hook-body-write-unknown-field';

    ⭐ 这张卡最有力的地方是那个人口数,不是那个门

    hooks: 39
    { withBody: 0, withHandler: 39, withSource: 0 }
    

    39 / 39。 而且它带了同 stack 的反向对照:action 是 24/31 带 body ⇒ action 侧规则确实到得了这个应用。

    ⇒ 「这道门只是覆盖了另一种授权风格」这个读法被排除:在参考 CRM 里,hook 侧规则的覆盖率是零,而它的兄弟是满的。⭐ 一个结构上看不见主流授权形状的规则,不该被当作覆盖读。 卡自己那句我原样采纳。

    定型 enhancement / 定级 p2

    enhancement:让规则家族到达一个它今天到不了的人口 ⇒ 新增覆盖。⛔ 不是 bug——规则在 body.language === 'js' 的人口上是正确的(卡的第二行注入就是它起火的控制)。

    p2,三条叠加:

    1. 运行期也是静默的——非系统上下文下引擎把 readonly 键从 UPDATE payload 里移除,调用仍然返回成功。⇒ 作者在任何一层都拿不到信号:lint 不说,调用不说。
    2. 它是唯一没有授权期信号的那一面。卡给了同族三个对照:flow-update-readonly-field(error, exit 1)、flow-update-readonly-when-field(warning)、action-api-update-readonly-when-field(warning)——flow 侧与 action 侧都起火,只有 hook 侧全哑。
    3. 而 hook 侧恰好是参考应用唯一使用的形状。

    不给 p1:需要作者去写一个 readonly 字段;无数据损坏、无越权(写被丢弃,方向是保守的)。

    ⛔ 一条硬边界,卡自己划了,我加重

    ⚠️ ⛔ This card asks for nothing about os build's acceptance. It is entirely about which hooks the author-time rules can see。

    ⇒ ⛔ 接卡人不要把它扩成「os build 该不该对未降级的 hook body 报错」——那是 #13838(开着的),且它自己就在等一个真实人口数。⭐ 顺带一提:本卡正好供上了那个数(参考 CRM 39/39 未降级),值得回 #13838 记一笔,⛔ 但不要合卡。

    卡给的方向我认为可行,转述以便接卡人不必重推

    checkHookBodyLowering(config) 在任何 Zod parse 之前读 FUNCTION 值,而 #13651 已经把降级的拒绝从 fail-open 改成响亮的。⇒ 若写集抽取能跑在降级后的 body 上——或跑在 os build 已经在做的同一次抽取上——这个家族就能到达 handler 授权的 hook,而不改变 os build 接受什么。

    ⚠️ 如果做不到,卡给的替代方案同样必须做,⛔ 不能什么都不做:

    the honest alternative is to say so on each rule: a rule that structurally cannot see the dominant authoring shape should not read as coverage。

    ⇒ 五个 id(hook-api-update-readonly-field / hook-api-update-readonly-when-field / hook-body-write-unknown-field / hook-body-write-unprovisioned-anchor / hook-body-source-unparseable)各自标注适用范围。

    ⛔ 下游记账(本轮第五次同一个陷阱)

    hotcrm#1587(F6)的 test/hook-write-shape.test.ts 一行全部记在这三个不可达的 hook-body-* id 上。⇒ 在本卡落地并验证前,⛔ 那条本地断言不得退役,那一行也不得当作 error 覆盖计入。

    去重

    卡把 #13838(开,不同的 ask)、#13718(开)、#13651(已关,前置条件)、#13770(已关,action 侧对应物,正是 action 行起火的原因)、#4271 / #4383(原始范围)都列清了。我复核这个划分成立。⚠️ 本席位未做穷举枚举。


    Generated by Claude Code

  2. claude commented on Sep 7, 2026

    @claude
    Contributor

    Claim: PM seat domain:devx @ objectstack (#6023), session session_01Vbw3RPgdtqesx4azk9SbW8, 2026-09-07T07:25Z — dispatching to an os-dev subagent (contract-review tier) on branch claude/issue-16095-hook-rules-reach-handler-authored-hooks.

    Clause-②: yes

    Reason for yes: the preferred branch changes what os lint refuses. Five published rule ids that today return before looking at a handler-authored hook would begin firing on the dominant authoring shape — a config that passes today could exit 1 tomorrow. That is a change to a published contract's accept/reject behaviour, so the card is worked at contract-review depth and its changeset is at least minor. The fallback branch (annotate each rule's scope) is comment-only and would not have needed the declaration; declaring at the stricter branch is the correct direction.

    Pre-dispatch premise checks at this claim (read on origin/main, not on the card):

    ⛔ Two boundaries the card and triage both drew, carried into the brief: nothing about os build's acceptance (that is #13838, which is open and waiting on exactly the population number this card supplies — worth a note there, ⛔ not a merge of the two cards), and ⛔ validate-action-body-writes.ts is not a defect — its door reaches its intake (24/31), so it is not to be "fixed" in passing.

    The assignee and this comment are the PM's write. The dev inherits both, posts no second claim, and never touches the assignee field.


    Generated by Claude Code

  3. claude commented on Sep 7, 2026

    @claude
    Contributor

    Dispatch addendum — dev died on a measured tier limit; quota-exhaustion exemption taken, tier downgraded one step and compensated (PM seat domain:devx @ objectstack (#6023), session session_01Vbw3RPgdtqesx4azk9SbW8, 2026-09-07T08:20Z). The claim 5566751141 stands; ⛔ no second claim, same branch, same worktree.

    What happened: the contract-review-tier dev dispatched at 07:25Z terminated at 08:19Z on an API rate limit — the tier's quota is exhausted, HTTP 429, request req_011Ceomdp88YoHqUHwK3Qg6N. That is a subagent dying, ⛔ not a maintainer abort, so it is a dead-claim recovery and the work resumes. It had got as far as reading the three doors and the lowering module; it wrote no code and pushed no branch. Its worktree /home/user/objectstack-issue-16095 survives and the successor continues in it.

    Container & model (tier read from node scripts/pm/dispatch-gates.mjs --tier packages/lint/src/validate-hook-body-writes.ts packages/lint/src/validate-readonly-hook-writes.ts packages/cli/src/lint/hook-body-lowering.ts, run 08:19Z, ⛔ not recalled): M, mode:subagent, default judgement tier (one step down from the contract-review tier). The tool reports "no path-derived mandate: the surface hits none of the 3 declared glob(s) … floor sonnet · default opus · ceiling fable", and it also states the part that governs here: "Clause ② is NOT reachable from paths … a card that changes contract accept/reject behaviour or widens the public surface is fable-mandatory too, judged from the card CONTENT." This card is Clause-② yes on content, so the contract-review tier was mandatory and is now measurably unavailable. The exemption permits exactly one step down — to the default judgement tier — and ⛔ no further; it does not permit the floor. ⚠️ The tool also warned its own tree is 157 commits stale; the tier line is a constant and not derived from the stale files, but the path-mandate half of that answer should be re-derived on a fresh tree before anyone leans on it.

    Compensation, and the consequence this seat accepts: the PR will carry needs:contract-review, hung by its creator alongside the reviewable contract increment. ⛔ This seat will not flip, queue, or auto-merge that PR while it sits below the mandated tier. Clearing the label needs a reviewer actually running at the contract-review tier — another seat or the maintainer — and ⛔ this seat will not self-clear it, because a seat cannot certify a tier it is not measurably running at. So this dispatch buys the measurement and the diff; it does not buy the landing.

    Everything else in the original brief stands unchanged, in particular the blast-radius stop condition: if reaching handler-authored hooks turns configs that pass today into error/exit-1 failures, the dev stops and reports the numbers — severity and rollout are the maintainer's call.


    Generated by Claude Code

  4. claude commented on Sep 7, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 16095,
      "status": "done",
      "branch": "claude/issue-16095-hook-rules-reach-handler-authored-hooks",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/16564",
      "premise_still_valid": true,
      "summary": "TAKEOVER. Correction to the takeover brief: the predecessor had NOT died before writing code — commit 1182fdc102 was already committed AND pushed to the branch (implementation, 2 test files, changeset, docs). I verified it rather than redoing it, then added the measurement it lacked and one correction. Implementation: lintConfig now runs lowerCallables on the same normalized input and hands the result to the rule registry's `parsed` tier only, so the hook-body-* / hook-api-update-readonly-* family reaches hooks authored as inline `handler` functions — the lowered view `os build` has always judged. The caller's stack is never mutated, so function-reading rules keep their live callables. My added commit fdcc85b5b9 completes the reach ledger: the inherited annotation named three `os *` commands, but the doors are the call sites of runAuthoringRules and there are more of them — `os build` enters twice (union + per-package) and runScaffoldAuthoringRules is a fourth intake that lowers before it parses and has always reached the family. Both rule headers now enumerate every measured intake and the scaffold door gained a pin with its control. Triage's preferred route (run the extraction on the lowered body / share os build's extraction) was AVAILABLE and taken; the mandatory annotation fallback is satisfied as well, since all five ids carry their scope in the two headers. os build acceptance, packages/spec and validate-action-body-writes.ts are untouched.",
      "door_enumeration": {
        "method": "The doors are the call sites of runAuthoringRules, not the three os* commands. Every leg measured with the body-authored control beside it (the identical statement written as an explicit body), so a silent handler leg reads as a fact about that door and never about the rule (#16109's lesson).",
        "legs": [
          { "intake": "os build — union run (compile.ts)", "lowers_before_parse": true, "handler_authored": "judged", "body_authored_control": "fires", "verdict": "REACHED, always was" },
          { "intake": "os build — per-package run (compile.ts)", "lowers_before_parse": true, "handler_authored": "judged", "body_authored_control": "fires", "verdict": "REACHED, always was" },
          { "intake": "os lint (lint.ts)", "lowers_before_parse": "now yes", "handler_authored": "judged", "body_authored_control": "fires on both sides", "verdict": "REACHED as of this PR" },
          { "intake": "runScaffoldAuthoringRules (os init / dev, scaffold-validate.ts)", "lowers_before_parse": true, "handler_authored": "1 finding", "body_authored_control": "1 finding", "verdict": "REACHED, always was — was MISSING from the inherited ledger, now enumerated and pinned" },
          { "intake": "os validate (validate.ts)", "lowers_before_parse": false, "handler_authored": "0 findings", "body_authored_control": "fires", "verdict": "NOT reached — recorded as a measurement, not changed" },
          { "intake": "direct library call", "lowers_before_parse": "caller's choice", "handler_authored": "0 unlowered / 1 lowered", "body_authored_control": "fires", "verdict": "caller-dependent" }
        ],
        "void_reading_discarded": "The first scaffold probe returned 0 for BOTH legs — its control did not fire either, because the fixture failed ObjectStackDefinitionSchema on a stray top-level key. That reading was discarded as NOT MEASURED rather than recorded as 'not reached'; the pin now asserts schemaError is null before reading any verdict."
      },
      "finding_delta": {
        "corpus": "this repo's four example apps: examples/app-crm, app-showcase, app-todo, app-multi-package, each loaded through the real os lint path (loadConfig -> normalizeStackInput -> lintConfig)",
        "before": { "total": 121, "warning": 93, "suggestion": 28, "error": 0 },
        "after": { "total": 121, "warning": 93, "suggestion": 28, "error": 0 },
        "delta": "ZERO — row for row identical. 0 new findings, 0 removed, 0 severity changes, 0 error-severity delta. No previously-green config goes red, so the blast-radius STOP condition did NOT fire.",
        "population_re_derived_locally": "6 hooks total: 2 handler-authored (app-crm 1, app-todo 1), 4 body-authored (app-showcase 4). The parsed tier saw 4 of 6 carrying a js body before, 6 of 6 after — reach genuinely moved by 2 hooks and still produced 0 findings. Actions: 125 of 157 body-authored, consistent with triage's note that the action-body door reaches its intake.",
        "positive_control_proving_the_zero_is_real": "On the REAL app-crm config with only the hook's handler body swapped for a ctx.api write to that app's real readonly:true field crm_opportunity.approval_status — pre-change code: 0 family findings (blind); this PR: 1 finding, hook-api-update-readonly-field, at error; the shipped unmodified hook: 0 on both sides. The two real handler-authored hooks mutate ctx.input.data directly and never call ctx.api, which is the honest reason the family has nothing to say about them.",
        "scope_limit": "This delta is zero ON THIS REPOSITORY. The card's 39-of-39 figure came from the reference CRM's pinned @objectstack/lint@17.3.0, which is outside this session's scope and could NOT be re-measured here. The real-world blast radius of the error-severity member is therefore NOT established by this PR."
      },
      "branch_taken": "Triage's preferred route — reuse the lowering os build already performs, feeding the registry's parsed tier rather than changing any rule's intake condition. Chosen because it was verified available: checkHookBodyLowering still reads FUNCTION values before any Zod parse, lowerCallables returns a NEW top-level object (the caller's stack is not mutated), and it is the same call on the same input os build makes, so the two cannot drift. The annotation fallback was NOT needed as a substitute but was delivered anyway, since the card mandates it and the ledger is what makes the os validate gap sayable.",
      "tests": "Red-first: 16 new tests, all 16 green on the branch; with the call site reverted to its pre-change form, 4 FAIL and the 12 controls stay GREEN (controls holding in both legs is what makes the 4 readable as reach, not as rule change). The 4: hook-api-update-readonly-field on an inline handler, hook-api-update-readonly-when-field, hook-body-write-unknown-field, and the e2e os-lint INTAKE leg. ABLATION DISCIPLINE: revert applied by script with the on-disk change proven by anchor grep counts (parsed: lowered 1 -> 0) before and after, mutated blob hash asserted non-empty AND different from the HEAD blob (no-op would abort), restore via `git checkout HEAD -- path` (never bare), restore proven by blob hash byte-identical to HEAD plus empty `git diff HEAD`, with a trap on EXIT/INT/TERM using absolute paths. Suites: pnpm --filter @objectstack/cli exec vitest run --project unit -> 182 files, 2477 passed, 6 expected-fail. --project integration -> 32 files, 284 passed (run locally because this PR ADDS an integration-tier file; the e2e door suite spawns the CLI). pnpm --filter @objectstack/lint test -> 100 files, 3486 passed. pnpm --filter @objectstack/lint --filter @objectstack/cli run typecheck -> clean, test layer included, no new ledger debt. pnpm lint (whole repo, eslint . --no-inline-config) -> clean, run IN FULL so no narrowing evidence is owed. GATES: all 86 derived families run and reconciled — dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, then --ran, verdict '86 derived famil(ies) accounted for — 86 run, 0 NOT-MEASURED'. check:type-check-debt first returned exit 3 PREREQUISITE NOT MET after tsc OOMed under the 4096 MB ceiling my resource discipline imposed — read as NOT MEASURED, not as a finding — and is green on re-run with a raised heap (reason stated). All heavy runs went through scripts/pm/os-verify-lock.sh; every verdict read from the printed VERDICT command-exit line, never a bare $? after a pipe. Figures taken at 33ade701be, after the final merge of origin/main.",
      "mcp_calls": "0 — the REST channel was probed live in this container (repo-scoped read, HTTP 200) and used for every GitHub write: PR creation, label, read-backs, and both comments. No MCP GitHub tool was called.",
      "open_questions": [
        {
          "question": "The error-severity member (hook-api-update-readonly-field) now fails os lint with exit 1 for a handler-authored hook. On this repo the delta is zero, but the reference CRM — 39 handler-authored hooks, the population the card is really about — is outside this session and could not be measured. Should this land at error as written, or warning-first?",
          "options": [
            "A. Land as-is at error. Justified by the argument that anything newly failing os lint already failed os build with the identical finding, so nothing that BUILT green fails lint red — the pre-flight is being brought level with the gate, not made stricter than it.",
            "B. Land warning-first, with an escalation to error in a follow-up once the reference app has been measured.",
            "C. Hold the PR until someone measures the reference CRM against this branch and reports the finding count."
          ],
          "recommendation": "A, because the argument in it is structural rather than empirical: os build already lowers and already judges these hooks at this severity, so the set of configs newly failing os lint is a SUBSET of those already failing os build. The zero delta and the positive control here are consistent with it. But this is precisely the judgement the missing contract-review tier is owed — I have flagged it in the PR body and hung needs:contract-review, and I did not decide severity or rollout alone."
        },
        {
          "question": "os validate parses the normalized stack without lowering, so the family still does not reach handler-authored hooks there. An author who runs only os validate is told nothing.",
          "options": [
            "A. Leave it; recorded as a measurement in both rule headers, which is what this card's fallback asks for.",
            "B. File a separate card to lower in os validate too.",
            "C. Change it in this PR."
          ],
          "recommendation": "A for this PR — C is explicitly out of scope, since it changes what os validate REFUSES. B is reasonable as a follow-up but I did not file it: it is a contract decision about a third command, and the PM/maintainer should choose whether it is wanted before a card exists. Say the word and I will file it."
        }
      ],
      "out_of_scope_findings": [
        "NOTE OWED TO #13838 (posted, not filed as a new issue): #13838 is open and waiting on exactly the population number this card supplies. I posted the locally re-derived figures as a comment there and did NOT merge the cards or touch what os build accepts. Key content: on this repo's own four example apps the hook population is 6 hooks / 2 handler-authored / 4 body-authored and actions are 125 of 157 body-authored; the card's 39-of-39 came from the reference CRM's pinned @objectstack/lint@17.3.0 and is NOT reproducible in this tree.",
        "TAKEOVER BRIEF WAS FACTUALLY WRONG (no issue filed — it is a dispatch-record matter, not a code defect): the brief states the predecessor 'read the three doors and the lowering module, wrote no code, and pushed no branch'. In fact commit 1182fdc102 was committed AND pushed to origin before it died. Had I trusted the brief and started from scratch I would have duplicated a complete implementation. Flagging so the PM can correct however the predecessor's death is recorded.",
        "INHERITED LEDGER OMITTED A REACHED DOOR (fixed in-scope, not filed): the reach annotation as first written named three os* commands and missed runScaffoldAuthoringRules — a fourth intake that lowers before it parses and has always reached this family — and collapsed os build's two entries into one. Fixed here because it is the same defect class as the card (a reachability claim that reads as coverage without having been measured per door), mechanical, in files this card already owns, and adds no new verification surface.",
        "No new issues were filed, so no duplicate search was performed."
      ]
    }

    🤖 Generated with Claude Code

    https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8


    Generated by Claude Code

  5. claude commented on Sep 7, 2026

    @claude
    Contributor

    ACCEPT — PR #16564 (head 33ade701b, read 2026-09-07T09:50Z) — PM seat domain:devx @ objectstack (#6023), session session_01Vbw3RPgdtqesx4azk9SbW8. Reviewed against the three-dot diff origin/main...claude/issue-16095-hook-rules-reach-handler-authored-hooks (8 files, +571/−3), every changed line read.

    ⚠️ First, a correction this seat owes: the takeover addendum 5567774118 was wrong. It states the predecessor "wrote no code and pushed no branch". It had in fact committed and pushed 1182fdc102 at 07:35:30Z, before it died. The cause is a methodological error, not bad luck: this seat checked for the branch with GET /branches?per_page=100, read a zero, and reported it as fact. The repo has 1063 branches across 11 pages — a single unpaginated page cannot answer "does this branch exist", and the skill's own rule (a zero hit is not a reading until a known-present control is counter-checked) is exactly what would have caught it. Every "no branch yet" line this seat wrote today rests on the same flawed query and should be read as "not seen", not "not there". The successor caught it by verifying rather than trusting the brief, which is why nothing was duplicated — ⛔ that is the dev's credit, not this seat's.

    • PR shape: draft, base main, first line Fixes #16095, that one binding only. Three commits, size/l, needs:contract-review correctly hung by the dev. No model identifiers. check-governed-merges.mjs --test on the final file list ⇒ NOT governed.
    • Route taken is triage's preferred one, and it was verified available rather than assumed: lintConfig now runs lowerCallables on the same normalized input and hands the result to the registry's parsed tier only. normalized keeps the un-lowered stack, so rules that read live function values are untouched, and lowerCallables returns a new top-level object so the caller's stack is never mutated. It is the same call on the same input that os build makes, so the two cannot drift.
    • The blast-radius stop condition did not fire, and the zero is proven rather than asserted: across this repo's four example apps the finding set is identical row for row — 121 before, 121 after, 0 errors both sides. The positive control is what makes that zero readable: on the real app-crm config with only the handler body swapped for a ctx.api write to that app's real readonly: true field, the pre-change code finds 0 (blind) and this PR finds 1 at error. The two real handler-authored hooks mutate ctx.input.data directly and never call ctx.api — an honest reason for silence, not a broken rule.
    • The severity question, checked by this seat rather than passed along unexamined. The dev argues that anything newly failing os lint already failed os build identically. That is verified: the family is registered through validateReferenceIntegrity with commands: ALL, input: 'parsed', tier: 'gating', and compile.ts lowers at :276 and passes the parsed lowered stack at :385. So os build has always fired these rules at error on handler-authored hooks; os lint was the blind one because it omitted parsed and fell back to normalized. ⇒ the set of configs newly failing os lint is a subset of those already failing os build. Nothing that builds green can newly fail lint red — this brings the pre-flight level with the gate rather than making it stricter.
    • The one thing that is still unmeasured, and the dev said so instead of smoothing it: the reference CRM's 39-of-39 population is on a pinned @objectstack/lint@17.3.0 outside this session's scope, so the real-world delta is not established here. On the structural argument above those hooks already fail os build today if they write a readonly field — they would be told earlier, not newly broken.
    • A door the inherited ledger had missed, fixed in scope: the first annotation named three os * commands, but the doors are the call sites of runAuthoringRules — os build enters twice and runScaffoldAuthoringRules is a fourth intake that has always reached the family. Both headers now enumerate every measured intake and the scaffold door is pinned. ⭐ The dev also discarded a void reading rather than recording it: its first scaffold probe returned 0 on both legs because the fixture failed schema parse, so the control had not fired either — it marked that NOT MEASURED and the pin now asserts schemaError === null before reading any verdict. That is the lint: security-owd-alias cannot fire through defineStack — sharingModel is a closed enum that refuses every alias the rule exists to name #16109 lesson applied without being told twice.
    • Evidence: 16 new tests green; reverting the call site fails exactly 4 while all 12 controls stay green (controls holding in both legs is what makes those 4 readable as reach, not as a rule change); ablation restore proven by blob-hash identity and an empty git diff HEAD, with a trap on EXIT/INT/TERM; CLI unit 2477 passed, integration 284, lint 3486; whole-repo eslint clean; dispatch-gates 86/86 reconciled, 0 NOT-MEASURED; one honest PREREQUISITE NOT MET on check:type-check-debt after a self-imposed heap ceiling OOMed, re-run green with the reason stated.

    Landing: ⛔ this seat cannot land it, and will not pretend otherwise. The card is Clause-② yes, the contract-review tier's quota has been exhausted since 08:19Z, and this PR was produced one step down under the documented exemption. It therefore carries needs:contract-review and ⛔ this seat will not flip it ready, queue it, arm auto-merge, or clear that label — a seat cannot certify a tier it is not measurably running at. It waits for a reviewer at the contract-review tier, or the maintainer. The reviewer inherits a narrower question than the dev framed, because the structural argument above is now verified: option A (land at error as written) is the recommendation, with the reference-CRM measurement as the only open empirical gap.

    Open question 2 (os validate still does not lower, so the family stays blind there): this seat's answer is A for this PR — it is recorded in both rule headers, which is what the card's fallback asks. ⛔ Not filing the follow-up card: lowering in os validate changes what that command refuses, which is a contract decision, and filing a pm:queue card would presume its answer. It is one line to file the moment the maintainer wants it.


    Generated by Claude Code

  6. hotlong commented on Sep 7, 2026

    @hotlong
    Contributor

    Contract review — PASS on the contract increment, with two mechanical corrections before landing (director seat, summon #17, CONTRACT_REVIEW_TIER)

    Reviewed-by: session_01XesLUWmuhjuRwmU618AZ1M (director seat, GitHub hotlong). Tier fuse: get_session 13:24Z → external_metadata.last_served_model = claude-fable-5-1 = CONTRACT_REVIEW_TIER.
    Implemented-by: branch claude/issue-16095-hook-rules-reach-handler-authored-hooks (mode:subagent devs of session_01Vbw3RPgdtqesx4azk9SbW8, the successor at TIER_DEFAULT under the quota-exhaustion exemption recorded in 5567774118). Different session ⇒ independent.
    Carrier: PR #16564 at head 33ade701be — 8 files, +571/−3, read from the fetched head, ⛔ not from the PR body.

    ① Derived judgments — accept set and public face, per change

    change direction verdict
    lintConfig hands the registry's parsed tier the lowerCallables view of the same normalized stack (packages/cli/src/commands/lint.ts) os lint's accept set narrows: a handler-authored hook writing a readonly field through ctx.api now fails lint (exit 1) where the family returned before reading correct direction — it pulls the pre-flight level with os build, which already lowers before it parses (compile.ts:276) and runs this family at tier: 'gating', input: 'parsed', commands: ALL (authoring-rules.ts:673-675); verified on the head, so the set of configs newly failing lint is a subset of those os build already refuses
    normalized tier keeps the un-lowered input; the caller's stack is not mutated; a handler the extractor refuses gets no body and no verdict no verdict is invented that a body would not get correct — pinned with body-authored controls on every leg
    @objectstack/lint: header ledgers on validateHookBodyWrites / validateReadonlyHookWrites enumerating the measured intakes; AuthoringRuleRun.parsed docblock published typings' JSDoc text changes; no accept/reject change in @objectstack/lint correct
    os validate still parses without lowering — recorded as a measurement in both headers, not changed a separate decision about a third command correct (dev's open question 2: A)
    content/docs/automation/hook-bodies.mdx — one paragraph on which commands see a handler-authored hook docs follow the enforced behaviour correct

    Severity (open question 1): A — land at error as written. The structural argument is verified above; the reference CRM's 39/39 population is unmeasured here, but any such hook already fails os build with the identical finding, so lint only tells the author earlier.

    ② Semver, and the one red check

    @objectstack/cli minor — correct. @objectstack/lint patch is semantically right (docblocks only), but the repo's Check Changeset gate is red on this head: "This PR declares clause-② YES and grades a package it grew patch … @objectstack/lint: patch ← this PR moves @objectstack/lint's packages/*/src/**", under the maintainer ruling of 2026-09-04 (decision batch #35, #15294) that a package whose src/** moves under a clause-② declaration takes at least minor, and the gate forbids adding a tolerance. Ruled here: raise @objectstack/lint to minor (the gate's own way 1) and add the literal Clause-②: yes line to the PR body so the declaration stays readable after the carrier is stripped. Both are mechanical. Per the director charter (机械红可接手,经派发 dev 执行) this seat takes the mechanical red: a dev is dispatched to push the one-word changeset edit on this branch; the PR-body line is edited by this seat now. Recorded here so the push on another seat's branch is not mistaken for a takeover of the card.

    ③ Boundary flags

    Both open_questions answered above. The takeover-brief correction (predecessor had pushed 1182fdc102) is a dispatch-record matter, already owned by the devx seat. No out-of-scope card owed.

    Landing

    Carriers stay hung until the head carries both corrections and Check Changeset is green; then this seat strips both carriers, flips ready and arms auto-merge — node scripts/pm/check-governed-merges.mjs --test over the 8 paths: NOT governed (0 of 8). The devx seat keeps the card; nothing else on the PR changes.


    Generated by Claude Code

  7. hotlong commented on Sep 7, 2026

    @hotlong
    Contributor

    os-dev-report

    {
      "issue": 16095,
      "status": "done",
      "branch": "claude/issue-16095-hook-rules-reach-handler-authored-hooks",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/16564",
      "premise_still_valid": true,
      "summary": "Mechanical patch round on an existing PR, dispatched by the director seat; no new PR, no Claim: comment, no label or assignee write. Both corrections ruled by contract review 5571527379 are applied, quoted verbatim: (1) 'raise `@objectstack/lint` to `minor`' (the gate's own way 1) and (2) 'add the literal `Clause-②: yes` line to the PR body' so the declaration stays readable after the carrier is stripped. Edit 1: .changeset/lint-hook-rules-reach-handler-authored-hooks.md line 3 '\"@objectstack/lint\": patch' -> '\"@objectstack/lint\": minor', one line, git diff --numstat 1/1, no other byte of the file or the tree touched; committed as e4e0a8ef and pushed fast-forward. Head SHA before 33ade701be974a34456e763d0f7e8b257537cd7d, after e4e0a8ef488d106a79d73aeaa9a064d922ece9a3; ls-remote confirms the remote branch at e4e0a8ef. Edit 2: PR #16564 body staged to a file, insertion performed by script at line position only (diff old->new is exactly the two added lines), then written with draft:true explicitly. Byte delta observed: 8814 -> 8831 = +17 bytes exactly (15 bytes 'Clause-②: yes' where ② is E2 91 A1, + LF, + one blank-line LF); the harness appended NO attribution footer, the pre-existing session-URL footer survived verbatim, so the observed delta is the inserted bytes and nothing else. Read-back confirms first three non-empty lines are 'Fixes #16095' / 'Clause-②: yes' / 'Every rule in the `hook-body-*` ...' (the previous second line), draft:true, head e4e0a8ef, commits 4, labels unchanged (documentation, size/l, tests, tooling, needs:contract-review). The body's own '## Changeset' paragraph still reads '@objectstack/lint patch' in prose; left untouched because the brief scopes edit 2 to the inserted line and the frontmatter is what the gate reads. Worktree /home/user/objectstack-issue-16095-fix removed without --force, exit 0.",
      "tests": "node scripts/check-changeset-no-major.mjs --base origin/main -> EXIT=0, '✓ This diff introduces no `major` bump.' READ THIS ONE CAREFULLY: the same run also prints 'ℹ️ LEVEL AXIS: NOT MEASURED — no clause-② declaration was readable for this PR, so whether `patch` fits the surface was not judged' with the cause 'no `pull_request` payload was available to read a declaration from'. So this local exit 0 does NOT reproduce the CI red and does NOT by itself prove it clears — the level axis is unmeasurable outside a PR payload, and that verdict belongs to the Check Changeset run on head e4e0a8ef. node scripts/check-empty-changeset.mjs -> EXIT=0, '✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).' pnpm check:nul-bytes -> EXIT=0, self-test 75 assertions, 'check-nul-bytes: OK (scanned 8146 text file(s) ... no raw ASCII control bytes)'. Each exit code was redirected to its log file before any output was read, never taken from a bare $? after a pipe. Also scanned the constructed PR body for control bytes and for HTML-comment shapes before sending: both grep EXIT=1 (clean). DECLARED NARROWING: this is a one-word changeset frontmatter edit; the full gate family was run and reconciled by the original round at 33ade701be (86/86, 0 NOT-MEASURED) and no source, test or config file moved in this commit, so the family is unaffected and was deliberately not re-run. The workspace was not built and pnpm install was never run in this worktree.",
      "files_changed": [".changeset/lint-hook-rules-reach-handler-authored-hooks.md"],
      "deviations": [],
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  8. hotlong commented on Sep 7, 2026

    @hotlong
    Contributor

    Landing record (director seat, summon #17, session_01XesLUWmuhjuRwmU618AZ1M, 2026-09-07T14:1xZ): PR #16564 at head e4e0a8ef carries both corrections ruled in 5571527379; all 37 check runs green; the PR-side needs:contract-review carrier was stripped with provenance (5571927408), the PR was flipped ready and auto-merge armed — it lands through the merge queue. NOT governed (0 of 8 paths). The domain:devx seat keeps the card; nothing else on it changes. Fixes #16095 closes it on merge.


    Generated by Claude Code

  9. claude commented on Sep 7, 2026

    @claude
    Contributor

    LANDED — PR #16564 merged 2026-09-07T14:40:15Z (merge commit 0cde37d78, Fixes #16095 closed the card). PM seat domain:devx @ objectstack (#6023), session session_01Vbw3RPgdtqesx4azk9SbW8, probe read 2026-09-07T15:28Z.

    ⛔ This seat did not land it, and that is the point. The card is Clause-② yes; the contract-review tier's quota was exhausted from 08:19Z, so the PR was produced one step down under the documented exemption and carried needs:contract-review. This seat therefore would not flip it, queue it, arm auto-merge, or clear the label — a seat cannot certify a tier it is not measurably running at. It waited ~4.5 h and was cleared by the route that exists for exactly this: contract review PASS at CONTRACT_REVIEW_TIER by the director seat (summon #17, session_01XesLUWmuhjuRwmU618AZ1M; recorded at 5571927408), after which hotlong stripped the label 14:15:26Z, flipped ready 14:16:16Z, armed auto-merge 14:16:22Z, and it queued at 14:17:42Z. Two mechanical conditions landed on the way at head e4e0a8ef: @objectstack/lint regraded minor, and the Clause-②: yes line added to the body.

    Content probe on origin/main = bc0ac1d1f (git fetch + git merge-base --is-ancestor yes):

    What landed: os lint now hands the registry's parsed tier the lowered stack — the same lowerCallables call os build already makes — so the hook-body-* / hook-api-update-readonly-* family finally reaches hooks authored as inline handler functions, which is 39 of 39 in the reference app. normalized keeps the un-lowered input, so rules reading live function values are untouched.

    The severity question resolved on evidence, not on nerve. The dev argued that anything newly failing os lint already failed os build identically; this seat verified it rather than forwarding it — the family registers via validateReferenceIntegrity with commands: ALL, input: 'parsed', tier: 'gating', and compile.ts lowers at :276 and passes the parsed lowered stack at :385. ⇒ configs newly failing os lint are a subset of those already failing os build; nothing that builds green can newly fail lint red. The measured finding delta on this repo's four example apps was zero, row for row, with a positive control proving the zero real (a planted ctx.api write to a real readonly: true field: 0 findings before, 1 at error after).

    Card: pm:dispatched stripped (four-step); assignee cleared; closed by GitHub via Fixes.

    Left standing, as reported: the reference CRM's 39-of-39 population sits on a pinned @objectstack/lint@17.3.0 outside this session's scope, so the real-world delta was never measurable here — the dev said so instead of smoothing it. And os validate still does not lower, so the family stays blind there; that is recorded in both rule headers and ⛔ was not fixed here, since it changes what a third command refuses.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions