Skip to content

stripReadonlyFields uses Object.is to tell a hook write from a caller write, so a hook cannot clear a readonly field the caller also sent as null #14088

Closed

Description

@os-warren

Found while building an ObjectStack application in objectstack-ai/duly against published @objectstack/* 17.2.0. Filed here because the fix lands in packages/objectql.

The defect

stripReadonlyFields decides whether a readonly field's value came from the hook or from the caller by comparing Object.is(result[name], supplied[name]). When both are null the comparison cannot distinguish:

  • the hook deliberately wrote null (a clear), from
  • the hook never touched it, and the caller's null should be stripped

Both are deleted together, so the hook's clear is silently discarded.

Concrete failure

duly_task has a readonly completed_at stamped by a beforeUpdate hook on the transition into done and cleared on the transition out. Reopening a completed task works — unless the caller also sends completed_at: null, which is exactly what a form round-trip of the whole record does. Then:

  • the hook sets completed_at = null
  • the strip sees Object.is(null, null) and removes the key
  • the persisted row keeps its old completion timestamp while status is in_progress
  • no error is raised

Measured, not inferred: the record comes back in_progress with a stale completed_at.

Why it is worse than it looks

The corrupted state is exactly the one an application would use a validation rule to make impossible — "a completed task must carry a completion timestamp" cannot catch its inverse, a non-completed task that still carries one. Nothing downstream can tell the row apart from a genuinely completed one, and any on-time metric reading completed_at counts it.

The common paths (a bare { status } reopen, a partial patch) are unaffected, which is why this survives casual testing.

Suggested direction

The distinction the strip needs is provenance, and Object.is on values cannot carry it. Tracking which keys the hook chain actually assigned — rather than inferring it from value equality — is the shape that closes it. A narrower fix would be to treat a key the hook explicitly set as hook-owned regardless of value equality.

Provenance

Reported by a developer agent implementing objectstack-ai/duly#3, and deliberately not worked around in the application hook — a consumer-side hack around a producer defect is how a workaround becomes permanent. That judgement was right, and it is why this is filed here.

Unassigned and untriaged, per the single-producer rule for domain:*.

Activity

  1. os-support-ai commented on Sep 1, 2026

    @os-support-ai
    Collaborator

    Claim: domain:engine execution PM · session session_01Q5WBDtaUnoz5XuJ6jk8pQ5 · R12 · branch claude/issue-14088-readonly-strip-provenance · worktree ../objectstack-14088-strip-provenance off origin/main.

    pm:queue → pm:dispatched.


    Zone 1 — BINDING. ⛔ NOT re-adjudicable.

    1.1 — The repair is PROVENANCE, not a smarter value comparison. The card's own diagnosis is the ruling: Object.is(result[name], supplied[name]) is being asked to answer who wrote this key, and value equality cannot carry that. ⛔ Do not ship a fix that special-cases null, or undefined, or any other sentinel — a sentinel fix leaves the identical hole open for every other value the hook and the caller can both send (0, '', false, a shared object reference). Track which keys the hook chain actually assigned.

    1.2 — ⛔ The forgery guard must survive intact. packages/objectql/src/engine-readonly-strip-signal.test.ts:290-292 pins exactly the pair this repair sits between:

    stripReadonlyFields(schema, { updated_by: 'hook-stamp' }, {})                        // hook wrote it → kept
    stripReadonlyFields(schema, { updated_by: 'attacker' }, { updated_by: 'attacker' })  // caller sent it → stripped
    

    A provenance record makes the first case easier to get right — and it also makes it possible to get catastrophically wrong, because a hook-owned key is a key the strip stops defending. ⛔ A caller value must never become hook-owned. If your provenance can be influenced by the caller's payload, you have built a privilege escalation, not a fix. This is the one place in this card where a mistake is worse than the bug.

    1.3 — Both update branches, one snapshot. engine-readonly-strip-caller-values.test.ts:218 records that stripReadonlyFields runs on both update branches off a single snapshot. ⛔ A repair that fixes one branch and leaves the other is not a fix; it is a divergence.


    Zone 2 — PM mechanism assumptions. ⚠️ MEASURE. Falsification welcome.

    2.1 — I assume the subject lives in packages/objectql/src/validation/rule-validator.ts, not in engine.ts. ⚠️ The card does not say this and I want it stated plainly, because I initially assumed otherwise and was wrong. engine-readonly-strip-signal.test.ts:43 imports { readonlyStripWarning, stripReadonlyFields } from ./validation/rule-validator.js. So the predicate is in rule-validator.ts; engine.ts is expected to be the call site you thread provenance through. Confirm both ends before you plan the diff.

    2.2 — I assume this card does NOT need the multi: true batch path. ⚠️ Load-bearing, and it is the reason you are dispatched alone rather than alongside #14099. #14099 (same reporter, same app, same duly_task.completed_at field) is the batch-hook defect and it lives on engine.ts's multi-update path. It is deliberately held for the next round, behind you, on the assumption that the hook-write provenance you build here is the instrument #14099 needs to detect "a hook mutated the payload" at all. ⭐ If you find your provenance record naturally answers #14099's question too, say so — that changes the next dispatch. ⛔ But do not implement #14099 here. One card, one PR.

    2.3 — I assume the two hook directions are symmetric. The card measures the clear (hook writes null, caller also sent null ⇒ both deleted). The stamp direction (hook writes a value the caller happens to have sent identically) should have the same defect by the same mechanism. ⚠️ Measure it — if only one direction is broken, the card's framing is wider than the tree and that is worth recording.

    2.4 — I assume the "common paths survive" claim. The card says a bare { status } reopen and a partial patch are unaffected, which is why this survived casual testing. ⭐ That makes them your negative controls — they must stay unaffected after the repair, and a test that proves they do is what distinguishes a targeted fix from one that quietly stops stripping.


    Zone 3 — advisory. ⛔ Not binding.

    The card names a narrower variant — "treat a key the hook explicitly set as hook-owned regardless of value equality" — which is provenance in the smallest form that satisfies 1.1. If the hook chain already returns or exposes a mutated-key set, that is the cheapest correct route. If it does not, minting one is the work.

    STOP conditions

    1. Provenance cannot be established without a signature change on a published hook contract ⇒ Clause ② fires ⇒ declare it and report before pushing.
    2. The repair reaches engine.ts's multi: true hook path ⇒ that is A multi: true update applies one hook-mutated payload to every matched row, so a transition-stamping hook corrupts rows that did not transition #14099's surface ⇒ stop and report; do not absorb it.
    3. Any route you find makes a caller-supplied key hook-owned ⇒ abandon that route, per 1.2.

    Verification expected

    The card's exact failure reproduced first (reopen with completed_at: null also sent by the caller ⇒ stale timestamp persists), then the same case passing. The forgery pin at :290-292 passing unchanged. Both update branches exercised. The two common paths (bare { status } reopen, partial patch) pinned as unaffected. A firing positive control on any zero. Plus a changeset, push, and a DRAFT PR saying Closes objectstack-ai/objectstack#14088.

    ⛔ Report anything unmeasured as NOT MEASURED. ⛔ No test skipped, disabled, quarantined, allow-listed or re-baselined. ⛔ Never git stash. ⛔ Never edit content/docs/releases/. Worktree-first.

    Clause-②: yes — changes the write path's accept behaviour for one input class (a hook-assigned readonly key the caller also echoed is now kept rather than stripped). Path limb: no.


    Generated by Claude Code

  2. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 1, 2026
  3. self-assigned this
    on Sep 1, 2026
  4. claude commented on Sep 1, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 14088,
      "status": "done",
      "branch": "claude/issue-14088-readonly-strip-provenance",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/14258",
      "premise_still_valid": true,
      "Clause-2": "no — no published hook contract changed signature. HookContext is untouched and hooks keep writing ctx.input.data.x = ... exactly as documented; the recording view is transparent for every read shape (pinned by a test). The only signature change is an added OPTIONAL member on stripReadonlyFields’ options bag, and that function is not re-exported from packages/objectql/src/index.ts, so it is an internal seam, not a published surface.",
      "summary": "The repair is PROVENANCE, per Zone 1.1: a new packages/objectql/src/hook-write-provenance.ts records the keys the before-phase hook chain ACTUALLY ASSIGNS (a write-through recording view of the payload), armed at hookContext construction in engine.update() and SEALED at the post-hook confluence objectstack-ai/objectstack#13657 already uses — the one point where hookContext.input.data is final on BOTH update branches. stripReadonlyFields takes an optional hookWrittenKeys and keeps a key a hook demonstrably assigned; the objectstack-ai/objectstack#5591 Object.is test stays as the fallback for every key the record cannot speak to. No sentinel: the 0 collision is pinned as its own test and fails identically under ablation. Zone 1.2 held structurally rather than carefully — the record holds nothing about the payload CONTENTS (only that an assignment ran), the window opens after the caller entry snapshot and closes before every engine-owned pass (encryptSecretFields / normalizeMultiValueFields / the strips themselves), and a caller cannot execute an assignment, so no caller-supplied key can enter it; it can only ever turn a STRIP into a KEEP. The forgery pin at engine-readonly-strip-signal.test.ts:290-292 is UNCHANGED and passes — it calls the function with no provenance argument, where behaviour is byte-identical to before. Zone 1.3 held: ONE sealed record, read by both branches at their own strip call, never re-derived. Zone 2.1 CONFIRMED at both ends (predicate lives in validation/rule-validator.ts; engine.ts is the call site the provenance threads through). Zone 2.2 held — dispatchPerRowBeforeHooks is not modified and no multi:true batch-hook behaviour changed; see note_for_pm_14099 for the answer that does affect the next dispatch. Known limit, pinned as a test: a hook that REPLACES the payload object leaves no attributable record and that call falls back to the old over-strip. That is the only safe direction — trusting a replacement’s keys would launder a caller forgery — so the fallback keeps the old bug rather than opening the lock. One repair outside the card that my own diff caused: check-system-context-census went red on pure line rot (the new import block shifts every subsequent engine.ts line by five), repaired with the gate’s own --fix, 15 anchors, no prose changed.",
      "tests": "All readings from tree efda8da2, taken AFTER the final commit. (1) FULL PACKAGE, unablated: pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2, through scripts/pm/os-verify-lock.sh — \"Test Files 253 passed (253)\" / \"Tests 4379 passed (4379)\", wrapper \"VERDICT command-exit 0\". (2) ABLATION = the reproduction of the card’s exact failure the dispatch asked for first. Mutation: the provenance consult line deleted from validation/rule-validator.ts. CONFIRMED ON DISK before running, never by an editor exit code — marker grep count 1 to 0, and blob 1f20551486cb9db8ffdb2adbfa7db0b2d0d01e60 to 0e478d6963d046999cc0e2f90d9f28f24f54f233. NO REBUILD NEEDED AND NONE DONE, stated rather than assumed: every import in these suites is relative and in-package (./engine.js, ./validation/rule-validator.js, ./hook-write-provenance.js) and packages/objectql/vitest.config.ts declares no alias, so vitest runs the TS source and no dist/ sits in the resolution path — the mutation changing behaviour with no build is itself the proof. Restore leg: absolute-path trap plus \"git checkout HEAD -- path\" (never a bare --), proven by comparing the restored blob back to the HEAD blob AND by \"git diff HEAD\" being empty, then re-confirming the marker count is 1. Ablated result: \"Tests 7 failed | 45 passed (52)\", and WHICH 7 is the finding. THE REPORT: \"expected 2026-08-01T09:00:00.000Z not to be 2026-08-01T09:00:00.000Z\" — the stale completion timestamp on a reopened task, the card’s measured row exactly. The 0 twin: \"expected 42 to be +0\". The STAMP direction: \"expected null to be 2026-08-05T10:00:00.000Z\". The PREDICATE branch clear: \"expected 2026-08-01T09:00:00.000Z to be null\". The PREDICATE branch stamp. onFieldsDropped reporting a hook clear as a drop. strictReadonlyWrites REFUSING a write whose only drop was the hook’s own clear. GREEN under ablation, i.e. the genuine negative controls: the bare { status } reopen, the partial patch, isSystem, all four forgery faces, the replacement-fallback pair, all 10 recorder unit tests, and the whole of engine-readonly-strip-signal.test.ts including the :290-292 forgery pin. The repair moves the collision cases and nothing else. (3) TYPECHECK: pnpm --filter @objectstack/objectql typecheck, \"VERDICT command-exit 0\" — and MEASURED to cover the new tests rather than assumed: tsc -p tsconfig.test.json --listFiles finds all three new/edited files (1 hit each), and the 242 error lines are exactly the ledgered check:test-typecheck debt with none attributed to them. (4) GATES: 33 derived by \"node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack\" with NO paths passed — it read the change set itself (6 paths vs merge base 66ecc50a9). Every exit code captured by redirect-then-read, never through a pipe. 30 exit 0. check-system-context-census exit 1 was MINE (pure line rot) and is repaired and re-run green. check-engine-split-ratio exit 2 = CANNOT COMPUTE (shallow container clone; a report-only gate whose exit 2 is documented as distinct from a finding) = NOT MEASURED locally. check-test-completeness exit 3 = PREREQUISITE NOT MET (it grades a saved turbo log, and its own text says to record NOT MEASURED) = NOT MEASURED locally. (5) LINT, as a DECLARED NARROWING: repo-wide pnpm lint was NOT run. The five changed source files were, --format json: 5/5 linted (none ignored), 0 errors / 0 warnings. Three evidences, so this is a measurement and not merely \"less\": the population came from eslint’s own resolution (all five appear in its JSON output, so none was ignored); the file count 5 was read from that JSON’s length; and configuration invariance holds because this repo runs one eslint.config.mjs that never enables type-aware linting for ANY file (stated at eslint.config.mjs:326-335 with its own measured positive control), so no untouched file’s verdict can move as a consequence of this diff. (6) NOT MEASURED, named as such: downstream consumer packages of @objectstack/objectql were not run locally (CI runs the farm); and the middleware window described in the filed finding was reasoned about but is exercised by no test here.",
      "mcp_calls": "1 — a single targeted search_issues for the out-of-scope dedup. Its result was non-empty (10 items, total_count 23), so it is self-validating and no control word was needed. Everything else went through zero-quota channels: the public-repo embedded-JSON payload for the objectstack-ai/objectstack#14088 and objectstack-ai/objectstack#14099 bodies plus all comments, git, and authenticated REST for the two writes.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as objectstack-ai/objectstack#14259: isCallerSuppliedValue (behind stripReadonlyWhenFields and stripReadonlyWhenFieldsMulti) and stripRuntimeOwnedFields (the insert-side twin) still decide hook-vs-caller by the Object.is comparison this card retired one function over — and the first documents itself as written to stay textually parallel with stripReadonlyFields, so after this PR the two disagree. Left alone deliberately rather than folded in: seam 1 is a state LOCK (#4889 frozen paid-invoice lines) whose hook exemption widening is a maintainer-shaped call, not a mechanical follow-through, and seam 2 is a different code path with its own fixtures, i.e. a new verification surface. The issue also names a third, narrower seam: the recorder arms at hookContext construction, so a MIDDLEWARE stamp writing the value the caller also sent is still judged by value equality. Unassigned, unlabelled, filed in the repo the repair lands in."
      ],
      "note_for_pm_14099": "ZONE 2.2 — THE ANSWER THAT CHANGES THE NEXT DISPATCH: yes, this record answers objectstack-ai/objectstack#14099’s detection question, and it answers the direction that card itself suggests. objectstack-ai/objectstack#14099 asks for an engine that can tell that a hook mutated the payload on a multi update, so it can split the SET clause or refuse at dispatch. After the seal, on the predicate branch, hookWrittenKeys IS that set — non-empty precisely when a batch hook wrote into the single shared SET clause — and it names WHICH keys, which is more than a refusal needs. It is in hand at the post-hook confluence, ahead of assertNoStrictDrops, evaluateValidationRules and driver.updateMany, so a refusal or a per-row fallback can be sited before anything is written. Two caveats the next implementer needs. (a) The record is undefined, not empty, when a hook REPLACED the payload; for objectstack-ai/objectstack#14099 that must read as \"cannot say\", because a refusal keyed naively on it would silently stop refusing for exactly the replacing hooks. (b) It cannot say WHICH row’s dispatch made the write — dispatchPerRowBeforeHooks accumulates onto one payload by contract — so it answers \"a hook wrote the shared clause\", not \"this value was derived for row 3\". Deliberately NOT implemented here: no change to dispatchPerRowBeforeHooks and no change to any multi:true batch-hook behaviour. The predicate branch participates only by reading the same sealed record at the same shared seam, which Zone 1.3 required."
    }

    Generated by Claude Code

  5. added
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    on Sep 2, 2026
  6. removed
    needs:contract-reviewMarker only — PR awaits its at-tier Contract review PASS; no check reads it, the record decides
    on Sep 2, 2026
  7. os-support-ai commented on Sep 2, 2026

    @os-support-ai
    Collaborator

    ✅ Contract review — PASS (in-seat, at tier) · PR #14258 @ 7a486e32 (re-confirmed on the post-merge head)

    Reviewer: domain:engine execution PM, session session_01Q5WBDtaUnoz5XuJ6jk8pQ5, reviewing the dev seat's contract increment. Provenance: maintainer ruling 2026-08-31 (in-seat review by an at-tier PM), re-authorised by the maintainer this shift: 「给你切换到 Fable了,你现在可以自己复审pr了」.

    Fuse reading, verbatim: get_session → external_metadata.last_served_model = claude-fable-5-1; CONTRACT_REVIEW_TIER = 'claude-fable-5'. ⚠️ Literal compare reads ≠; the served model is the newer Fable and the maintainer authorised explicitly. Recorded, not hidden.

    Carriers: --pair 14258 → exit 0 with the gate hung on both. Clause-②: yes — the write path's accept behaviour changes for one input class (a hook-assigned readonly key the caller also echoed is now kept, not stripped). Path limb no.

    Head movement, measured — the review binds to the code, and the code did not move

    The review was taken on efda8da2. The PR then went mergeable_state: dirty on content/docs/permissions/system-context.mdx (main landed #14249 and re-anchored other rows of the same generated page — zero source overlap, the third instance of this class this term). The #14088 seat applied the prescribed remedy: merge origin/main (merge commit 47df730c), take main's page wholesale, regenerate on the merged tree (7a486e32, "chore(docs): re-anchor the census page on the merged tree").

    reading result
    commits added, first-parent exactly 2: the merge + the regen
    the six PR-owned files, git patch-id of the PR diff before vs after the merge (against each merge-base) SAME ×6 — engine.ts, rule-validator.ts, hook-write-provenance.ts, both test files, the changeset
    census page vs current main 12 rows, every one an engine.ts anchor (18–24, 27–29, 62, 63, "skips validation rules") — exactly the rows this PR's engine.ts shift moves; no prose changed
    mergeable_state dirty → blocked (draft + suite running); git merge-tree onto current main CLEAN
    governed-surface test on the final file list clean

    ⇒ The reviewed code is byte-identical; only the generated artifact was re-derived with the repo's own tool. The PASS carries over to 7a486e32 without re-reading the diff.

    ① Derived judgments (unchanged from the efda8da2 reading)

    change judgement
    New hook-write-provenance.ts: a Proxy set/defineProperty/deleteProperty recorder over the raw update payload, armed after the caller's entry snapshot, sealed at the #13657 confluence before any engine-owned pass Correct, and the forgery boundary is structural, not careful: it records only that an assignment executed, never contents; a caller cannot execute a set; a __proto__ write can never name a schema field; symbol keys ignored.
    seal(current) returns no record when a hook replaced the payload object Correct fail-safe direction — falls back to the pre-#14088 over-strip rather than laundering a replacement's keys as hook-owned. Pinned twice.
    stripReadonlyFields gains optional hookWrittenKeys; consulted before the Object.is test; the value test stays as the fallback for every key the record cannot speak to Correct. Absent provenance ⇒ byte-identical behaviour; the forgery pin at engine-readonly-strip-signal.test.ts:290-292 untouched and passing. A caller-supplied key can never become hook-owned.
    Both update branches (by-id :10997, predicate :11162) consume the one sealed record Correct — Zone 1.3; a bulk/by-id authorship divergence would be #3106 / #4441 verbatim.
    Not a null sentinel — the 0 twin pinned and failing under ablation alongside Correct — Zone 1.1.
    INSERT-path strip and the readonlyWhen sibling strips still decide by Object.is Out of scope, filed by the dev as #14259. Not a blocker for this card.
    multi: true batch-hook path (#14099) Untouched, as fenced. ⭐ The dev confirms the sealed hookWrittenKeys is exactly #14099's detection instrument on the predicate branch — recorded on the seat post for the next dispatch.

    ② Semver

    @objectstack/objectql: patch — stripReadonlyFields is not in the package barrel (index.ts exports evaluateValidationRules, needsPriorRecord, legalNextStates from that module, not the strip), so the new optional parameter is internal; the change is a bug fix. Consistent with the changeset.

    ③ Boundary flags

    • Zone 2.3 (stamp direction) measured broken symmetrically and fixed with the clear; Zone 2.4 negative controls (bare { status } reopen, partial patch, isSystem) all pinned unaffected and green under ablation. Ablation: 7 failed / 45 passed, and the 7 are exactly the collision cases.
    • Repo-wide pnpm lint declared as a narrowing (five files linted; no type-aware rules exist in this repo's config) — accepted as a measured narrowing.

    Verdict: PASS on 7a486e32. Same stroke: gate cleared on both carriers. Enqueue waits on the suite for this head — all checks green is the bar, not the required subset. Landing record at MERGED, verified by content.


    Generated by Claude Code

  8. claude commented on Sep 2, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 14088,
      "status": "done",
      "round": "patch — generated-artifact merge conflict resolved; implementation unchanged",
      "branch": "claude/issue-14088-readonly-strip-provenance",
      "head": "7a486e32f78821e3decda1c49dddb767c8ad77b7",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/14258",
      "mergeable": "true (was the reported dirty). mergeable_state reads \"blocked\", NOT \"dirty\" — on a draft PR with checks still settling that is the expected aggregate, and the conflict field is the one that answers this round. Cross-checked with the decisive zero-quota reading: \"git merge-tree --write-tree origin/main HEAD\" against the CURRENT origin/main (d63c8a25216c59b9bf2652cf5197aa526a2c00b2, already two commits past the main I merged) exits 0 with a single tree oid and no conflict lines.",
      "premise_still_valid": true,
      "Clause-2": "no — unchanged from the delivery round. No published hook contract changed signature; HookContext is untouched.",
      "summary": "Purely the generated-artifact collision class, exactly as diagnosed — no implementation change. Fresh worktree on the existing remote branch (the delivery worktree was torn down on delivery), then \"git merge origin/main\" as a MERGE COMMIT; no rebase, no amend, no force-push, no stash. One thing worth recording because it is a trap: the merge SUCCEEDED with exit 0 and left no conflict markers. The repo has a custom merge driver for content/docs/permissions/system-context.mdx that refuses to text-merge it and prints \"not text-merged — it is generated\", and what it left in the tree was THIS BRANCH’S pre-merge copy — byte-identical to my side. Committing that would have silently dropped the rows PR objectstack-ai/objectstack#14249 added on main, and nothing about the merge’s exit code says so. So main’s copy was taken wholesale (\"git checkout origin/main -- PAGE_PATH\", verified byte-identical to origin/main’s blob) and the anchors re-derived from the MERGED tree with the repo’s own tooling, \"pnpm gen:system-context-census\" (which is the census gate’s own --fix). It did NOT refuse, so this is line rot and not a population change: 15 anchors rewritten — the same 15 this branch’s engine.ts insertion shifts — and the census totals are unchanged at 109 elevation read sites / 145 anchors / 27 declared non-read. Verified against main’s copy: exactly 12 rows differ and every single difference is an engine.ts line number inside backticks, all prose byte-identical, so objectstack-ai/objectstack#14249’s rows survive. The pre-commit hook independently confirmed the deferred artifact was current and cleared its marker.",
      "tests": "All readings re-run on the MERGED tree 7a486e32f — not carried over from the pre-merge head. (1) CARRY-OVER PROOF FIRST: all six content files of this PR are byte-identical across the merge, compared by blob id with git rev-parse on each path, efda8da21 vs 7a486e32f — hook-write-provenance.ts, hook-write-provenance.test.ts, engine.ts, validation/rule-validator.ts, engine-readonly-strip-caller-values.test.ts and the changeset. Only the generated page moved. That is what licenses the delivery round’s ablation reading to stand: the file it mutates, validation/rule-validator.ts, is the same blob at both heads. (2) FULL PACKAGE on the merged tree: dependency closure rebuilt first (pnpm --filter \"@objectstack/objectql^...\" build, VERDICT command-exit 0) because main moved packages/spec and packages/types underneath it; then pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 — \"Test Files 253 passed (253)\" / \"Tests 4379 passed (4379)\", VERDICT command-exit 0. Identical to the pre-merge run, so the merge changed nothing behavioural. (3) TYPECHECK on the merged tree: pnpm --filter @objectstack/objectql typecheck, VERDICT command-exit 0, check:test-typecheck OK with the same 44 files / 242 errors / 69 pinned signatures ledger. (4) THE GATE THIS ROUND IS ABOUT: pnpm check:system-context-census exit 0 on the merged tree, self-test included. (5) GATE FAMILY RE-DERIVED on the merged tree, no paths passed — node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack read the change set itself (7 paths vs merge base a39b02a6b). It grew 33 to 58 because the change set now carries a content/docs/** path, which pulls in the whole docs family; nothing dropped out, and main had rewritten dispatch-gates.mjs by 710 lines, so re-deriving rather than reusing the old list mattered. All 58 ran, exit captured by redirect-then-read, never through a pipe. 54 green, plus check:nul-bytes and check-engine-double-contract green outside the derived list. (6) FOUR NON-GREENS, none a finding: check-engine-split-ratio exit 2 = CANNOT COMPUTE (shallow container clone; report-only gate whose exit 2 is documented as distinct from a finding) = NOT MEASURED. check-test-completeness exit 3 = PREREQUISITE NOT MET = NOT MEASURED. And two that are NEW to this round only because the docs path widened the family, neither a green that turned red: check:dual-build-cjs-loads exit 3 and spec check:skill-examples exit 1, both PREREQUISITE NOT MET for the same reason — they read built dist/ and this container built only the objectql closure, not all packages. Both REFUSE rather than conclude, in their own words \"⛔ This is NOT a pass: nothing was measured\" and \"a verdict now would be … a FALSE GREEN\", and both name packages this diff does not touch (client-react, hono, apps/*). NOT MEASURED locally; CI builds everything. (7) NOT MEASURED, named as such: the ablation was not re-run on the merged tree (unnecessary and stated why — the mutated file is the same blob), repo-wide pnpm lint was not re-run (the delivery round’s declared narrowing still holds, and this round changed only a generated .mdx), and downstream consumer packages were not run locally.",
      "mcp_calls": "0 this round. The merge, the resolution, the gates and every verification ran on git and local tooling; the PR body update and the mergeability readings went through authenticated REST. Cumulative for the card: 1.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as objectstack-ai/objectstack#14259 in the delivery round, unchanged and not duplicated: isCallerSuppliedValue (behind stripReadonlyWhenFields and stripReadonlyWhenFieldsMulti) and stripRuntimeOwnedFields still decide hook-vs-caller by the Object.is comparison this card retired one function over."
      ],
      "note_for_pm": "Two things from this round that are about the repo rather than about this card, offered and not acted on. (a) A merge that touches this generated page exits 0, prints its guidance to stdout, and leaves the BRANCH’S copy in the tree — so the failure mode is a silent loss of whatever main added to the page, and the only thing standing between that and a landed commit is the pre-commit hook plus the author reading the driver’s message. It cost nothing here because the guidance was followed, but \"exit 0 and the wrong side kept\" is the shape that gets missed under time pressure. (b) The gate family for a card is not stable across a merge: this one went 33 to 58 purely because a generated docs artifact entered the change set, and main had rewritten dispatch-gates.mjs by 710 lines in between. Re-deriving on the merged tree rather than reusing the dispatch-time list is what caught the four prerequisite refusals; a seat reusing its old list would have reported a clean sweep it never ran."
    }

    Generated by Claude Code

  9. os-support-ai commented on Sep 2, 2026

    @os-support-ai
    Collaborator

    ✅ LANDED — PR #14258 merged as 356bd71a (merge queue, 2026-09-02T02:25Z), verified by content on origin/main

    pm:dispatched → stripped. Card closed by the PR's Closes objectstack-ai/objectstack#14088.

    Discriminating probe (⛔ not the API's merged field): recordHookPayloadWrites — absent in engine.ts on both the original base 66ecc50a and the post-merge base a39b02a6; hook-write-provenance.ts does not exist on either base (positive control on the same base: stripReadonlyFields = 7 hits in the same file). On origin/main: engine.ts ×2, the new module present, rule-validator.ts hookWrittenKeys ×5. Last-touching commit of packages/objectql/src/engine.ts on main is 356bd71a, this PR's merge.

    Fences held post-merge: 7 files; packages/spec 0; the multi: true batch-hook path untouched (#14099 stays open and now has its instrument). Clause-② (yes, write-path accept behaviour) reviewed in-seat at tier — PASS on efda8da2, re-confirmed on 7a486e32 after the census-page merge (six code files byte-identical by patch-id; only the generated page re-derived).

    Delivered: the static readonly strip decides by hook-write provenance — a recording of which keys the before-phase hook chain actually assigned, armed after the caller's snapshot and sealed before any engine-owned pass — so a hook can clear (or stamp) a readonly field the caller also echoed; Object.is stays as the fallback for every key the record cannot speak to; a hook that replaces the payload object gets no record and falls back to the old over-strip (fail-safe); the forgery pin untouched. Both update branches consume one sealed record. objectql patch.

    Post-landing re-check of the landing criterion itself: the census page went through the queue's own os-regen merge. A local re-run of check-system-context-census on the landed tree could not resolve modules in a detached verification worktree — NOT MEASURED locally; the reading is the queue's merge_group run and main's push CI (recorded on the seat post).

    Follow-ups on the board: #14259 (the readonlyWhen and insert-side runtime-owned strips still decide by Object.is — dev-filed) · #14099 (multi: true applies one hook-mutated payload to N rows — ⭐ the sealed hookWrittenKeys on the predicate branch is exactly its detection instrument; dispatch it from there).


    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

Labels

bugSomething isn't workingdomain:engineRetired 2026-10-22, alias of domain:server (engine surface); in-flight cards keep it until they landpriority:p1High: required for production / M2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions