Skip to content

docs(objects): audit the readonly notes per writer, not per sentence - #1670

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-1435-readonly-blanket-writer-audit
Sep 6, 2026
Merged

docs(objects): audit the readonly notes per writer, not per sentence#1670
os-steve merged 1 commit into
mainfrom
claude/issue-1435-readonly-blanket-writer-audit

Conversation

@os-steve

@os-steve os-steve commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Closes #1435

Six object fields justified a missing readonly declaration with "the platform drops writes to readonly fields". #1429 measured that false as stated. This corrects all six on their own evidence — the audit table below is the deliverable, the comments are its conclusion.

Step 0 — the mechanism, re-confirmed at 17.3.0 rather than inherited

#1429 measured on 17.1.0 and #1460 re-measured on 17.2.0; package.json now pins 17.3.0 across every @objectstack/*. Re-confirmed here two ways:

Source, on the installed 17.3.0. @objectstack/objectql/dist/core.js:12728 — the by-id update path — still reads:

if (!opCtx.context?.isSystem) {
  hookContext.input.data = stripReadonlyFields(updateSchema, preRo, suppliedValues, this.logger, {...});
}

and the multi path (:12776) is the same shape. Inside stripReadonlyFields the caller-supplied discipline is intact — hasOwnProperty(supplied, name) plus Object.is(result[name], supplied[name]). resolveRunDataContext (@objectstack/service-automation/dist/index.js:155) still returns isSystem: true when and only when context.runAs === 'system'. The insert path (core.js:12341) calls stripRuntimeOwnedFields — autonumber-class columns — and never stripReadonlyFields, so a statically readonly column is not stripped on INSERT.

Behaviour. pnpm exec vitest run test/readonly-write-semantics.test.ts22/22 passed; test/activity-recency.test.ts23/23 passed (that one drives the cross-record ctx.api write through the real engine under a plain user context).

Two things postdate the earlier reading and are worth recording:

addition effect does this repo hit it?
hookWrittenKeysif (hookWrittenKeys?.has(name)) continue; "hook-written keys are not caller-supplied" is now stated in the engine instead of emerging from the value-identity check yes, and it makes the hook exemption sturdier than when it was inferred
strictReadonlyWrites — opt-in per call a stripped key REFUSES the whole write with ERR_READONLY_FIELD_REJECTED instead of committing without the column nogrep -rn strictReadonlyWrites src/ test/ is empty, so drop-and-commit still describes this repo

The audit — field → writers → effective runAs → can it be readonly?

runAs is written effective: undeclared on a flow is 'user', undeclared on a hook is 'inherit' (so ctx.api carries the acting context).

1. src/objects/lead.object.tsis_converted + the converted_* block

writer kind effective runAs survives readonly?
lead_conversionmark_converted flow update_record 'user' — no runAs declared; screen flow, and house rule 9 keeps it there ❌ stripped
guest-submission sanitiser (lead.hook.ts) beforeInsert assignment to ctx.input.data n/a ✅ hook-written key, and INSERT is exempt anyway
seeds INSERT n/a

Verdict: cannot be readonly.Stop condition hit — see below.

2. src/objects/quote.object.tssubtotal / discount_amount / total_price

writer kind effective runAs survives readonly?
quote_total_rollup (quote_line_item.hook.ts, after insert/update/delete) api.object('crm_quote').update(...) — cross-record ctx.api 'inherit' → the acting user ❌ stripped
quote_generationcreate_quote flow create_record 'user' ✅ INSERT is exempt
revenue.seed.ts INSERT n/a

Verdict: cannot be readonly — but the old note named the wrong writer. The create-time flow write it blamed would have survived untouched; it is the line-item rollup that keeps the columns open.

3. src/objects/opportunity.object.tsapproval_status (+ approved_date)

writer kind effective runAs survives readonly?
opportunity_approvalmark_approved / mark_rejected flow update_record 'system' — declared and argued in that file ✅ strip branch skipped
opportunity_approval_on_create same nodes 'system' — inherited via { ...OpportunityApprovalFlow }
the approval node's approvalStatusField write platform approval machinery inside those runs 'system'
seeds INSERT n/a
any user none exists: on no view/page form, written by no action

Verdict: it CAN honestly be declared readonly: true. Filed as #1666, not flipped here — removing a column from the editable surface is a behaviour change, not a comment correction.

4. src/objects/campaign_member.object.tsadded_date

writer kind effective runAs survives readonly?
campaign_enrollmentcreate_campaign_member flow create_record 'user' ✅ INSERT is exempt
campaign_enrollmentcreate_contact_member flow create_record 'user'
marketing.seed.ts INSERT n/a
anything at all UPDATE no UPDATE writer exists

Verdict: it CAN honestly be declared readonly: true. Filed as #1667.

5 & 6. src/objects/campaign.object.tsactual_revenue and the num_* block

The defended set is CAMPAIGN_METRIC_FIELDS in campaign.hook.ts.

writer trigger object / events effective runAs survives readonly?
campaign_metrics_refresh crm_campaign afterUpdate 'inherit' → acting user ❌ stripped
campaign_attribution_refresh crm_opportunity after insert/update/delete 'inherit' → acting user
campaign_lead_conversion_refresh crm_lead afterUpdate 'inherit' → acting user
campaign_member_metrics_refresh crm_campaign_member after insert/update/delete 'inherit' → acting user
seeds INSERT n/a

All four write api.object('crm_campaign').update(...); none declares Hook.runAs. Their least-privileged trigger is an ordinary user edit — a rep moving a campaign's status, saving an opportunity, enrolling a member.

Verdict: cannot be readonly. Both notes also stopped naming campaign_snapshot_metrics, a hook retired long ago (four further live files still do — #1668).

The three version-scoped sites, handled distinctly

The card's classification of which sites are version-scoped is off by one, measured: quote.object.ts:177 also says "16.x" — the word sits on the line above the one the card quoted. So the split is two unconditional (lead, opportunity) and four version-scoped (quote, campaign_member, campaign ×2), not 3/3.

  • Unconditional claims are simply false on the pinned engine ⇒ replaced with the measured rule for that site's own writers.
  • Version-scoped "16.x" claims are a different failure. They may well have been true of 16.x — but 16.x is not installed, the pin is 17.3.0, and a justification the next reader cannot re-run is not a justification. So the 16.x citation is dropped rather than restated, and replaced by the reason that holds on the pinned engine.
  • Where the current mechanism reaches a different verdict than the historical claim did (added_date: its writers are inserts, which the strip never reaches), the note says so outright instead of quietly keeping the old conclusion.
  • #2948 survives only where it still cites the right behaviour.

opportunity.object.ts:139's already-precise statement is untouched, and test/case-first-response.test.ts:75 is out of scope as the card directs.

⛔ Stop condition — hit, reported, not applied

Three sites could take readonly if a write were elevated: crm_lead.is_converted (elevate lead_conversion's mark_converted), crm_quote pricing and the crm_campaign metric block (declare Hook.runAs: 'system' on the rollup/refresh hooks — the engine grew HOOK_RUN_AS_VALUES = ['system','user','inherit'] and no hook in this repo declares it).

PR #1575's pattern applies most cleanly to the lead case and least cleanly to the others:

⛔ Neither is applied here. No flow was touched; the diff is confined to src/objects/*.object.ts.

Verification

pnpm verifyfully green, exit 0.

step result
validate ✓ Validation passed (1327ms)
typecheck clean
lint clean
lint:i18n-gate ✓ 0 i18n/missing-* issues
hygiene ✓ source hygiene clean
hygiene:tokens ✓ source token ratchet clean (comment-stripped, so these edits do not move it)
build ✓ Build complete (1363ms)
test 161 files passed, 3402 passed / 1 skipped, 131s

Out of scope, filed

# what
#1666 crm_opportunity.approval_status / approved_date can honestly be readonly
#1667 crm_campaign_member.added_date can honestly be readonly
#1668 campaign_snapshot_metrics retired, still named in four live files
#1669 readonly-write-semantics.test.ts header says the pin is 17.2.0; it is 17.3.0

⛔ Do not enqueue, auto-merge, approve or merge — draft, awaiting maintainer review of the two readonly findings and the elevation question.

🤖 Generated with Claude Code

https://claude.ai/code/session_018xtjdpZFjgWh4Ad9Wcx68J


Generated by Claude Code

Six object fields carried a note justifying a missing `readonly` declaration
with "the platform drops writes to readonly fields". That claim is false as
stated: the strip is one branch of the UPDATE path, `if (!opCtx.context
?.isSystem)`, over caller-supplied keys only, so the answer depends on the
least-privileged writer of each field.

Re-confirmed on the pinned 17.3.0 rather than inherited from the 17.1.0 reading:
the guard still reads as quoted in `@objectstack/objectql/dist/core.js` (both the
by-id and the multi update paths), `resolveRunDataContext` still returns
`isSystem: true` for `runAs: 'system'` and only for it, the insert path calls
`stripRuntimeOwnedFields` and never `stripReadonlyFields`, and the measurement
suite is green. Two additions postdate the earlier reading: `hookWrittenKeys`
now states the hook-write exemption in the engine, and an opt-in
`strictReadonlyWrites` refuses the whole write instead of committing without the
column — unused here, so drop-and-commit still describes this repo.

Audited per site, and the six do not agree:

- `crm_lead.is_converted` + the `converted_*` block: `lead_conversion` declares
  no `runAs`, so its `mark_converted` update runs as `'user'` and is stripped.
- `crm_quote` pricing: the deciding writer is the line-item rollup's
  cross-record `ctx.api` update, not the create-time flow write the note named —
  an INSERT, which the strip never reaches.
- `crm_opportunity.approval_status` / `approved_date`: every writer is the
  `runAs: 'system'` approval flow or an insert, so these could be declared
  `readonly`. Recorded, not flipped.
- `crm_campaign_member.added_date`: written only by INSERTs. Same.
- `crm_campaign.actual_revenue` + `num_*`: four refresh hooks write them through
  `ctx.api` under the acting user, so they must stay open. The notes also stop
  naming `campaign_snapshot_metrics`, retired long ago.

Three sites scoped their claim to "16.x". A historical statement this repo can
no longer re-run is not a justification, so the citation is dropped rather than
restated, and where the current mechanism reaches a different verdict the note
says so instead of keeping the old conclusion.

Co-authored-by: Claude <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
hotcrm Ignored Ignored Sep 6, 2026 8:39am UTC

Request Review

@github-actions github-actions Bot added the metadata Declarative metadata — schema, security posture, UI surfaces label Sep 6, 2026
@os-steve
os-steve marked this pull request as ready for review September 6, 2026 08:45
@os-steve
os-steve added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit b63e0e7 Sep 6, 2026
10 checks passed
os-steve pushed a commit that referenced this pull request Sep 6, 2026
`campaign_snapshot_metrics` was retired in #597 and the campaign metric block is
now owned by four refresh hooks — `campaign_metrics_refresh`,
`campaign_attribution_refresh`, `campaign_lead_conversion_refresh` in
`campaign.hook.ts`, and `campaign_member_metrics_refresh` in
`campaign_member.hook.ts`. Several live comments still named the retired hook as
the current writer.

A tree-wide grep found nine occurrences over seven files, not the four the audit
listed. Each was read on its own terms rather than renamed in bulk, because
several of them narrate what changed and are meant to keep the old name.

Named the real writer where the comment claimed the hook was live:

- `campaign-completion.flow.ts` called it "the existing `campaign_snapshot_metrics`
  afterUpdate hook" that "then snapshots into metrics". The nightly flip is a
  `status` transition, so `campaign_metrics_refresh` recomputes on it — and the
  block was already current before the sweep ran, so the note says refresh
  rather than snapshot.
- `sales.seed.ts` described the opportunity `crm_campaign` link as what the
  retired hook "counts when a campaign completes". `campaign_attribution_refresh`
  is what counts attributed opportunities, on every insert, update and delete.
- `seed-consistency.test.ts` listed the retired hook in its hook-to-field map and
  named a `describe` block after it. Both now name the refresh, and the block
  carries the four hook names. The `it` inside that still explained its assertion
  as a completion-time snapshot was corrected as well: the renamed block would
  otherwise have attributed completion-only firing to the four refresh hooks.

Marked as history where the mention is deliberate:

- `campaign-member-lifecycle.test.ts` needs the old name to explain why a
  completion-time assertion cannot prove the acceptance criterion. It now opens
  "The RETIRED `campaign_snapshot_metrics` — gone since #597" instead of leading
  with a bare hook name in the past tense.

Left alone, already self-evidently historical: `campaign.hook.ts` ("The old …"),
`campaign_member.hook.ts` ("the removed …"), `campaign.object.ts` ("the
long-retired …", corrected by #1670) and that PR's changeset ("a hook retired
long ago").

No assertion changed: comment, block-name and prose only. `pnpm verify` green.

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

metadata Declarative metadata — schema, security posture, UI surfaces

Projects

None yet

2 participants