docs(objects): audit the readonly notes per writer, not per sentence - #1670
Merged
Merged
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
os-steve
marked this pull request as ready for review
September 6, 2026 08:45
This was referenced Sep 6, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1435
Six object fields justified a missing
readonlydeclaration 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.jsonnow 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:and the multi path (
:12776) is the same shape. InsidestripReadonlyFieldsthe caller-supplied discipline is intact —hasOwnProperty(supplied, name)plusObject.is(result[name], supplied[name]).resolveRunDataContext(@objectstack/service-automation/dist/index.js:155) still returnsisSystem: truewhen and only whencontext.runAs === 'system'. The insert path (core.js:12341) callsstripRuntimeOwnedFields— autonumber-class columns — and neverstripReadonlyFields, so a staticallyreadonlycolumn is not stripped on INSERT.Behaviour.
pnpm exec vitest run test/readonly-write-semantics.test.ts→ 22/22 passed;test/activity-recency.test.ts→ 23/23 passed (that one drives the cross-recordctx.apiwrite through the real engine under a plain user context).Two things postdate the earlier reading and are worth recording:
hookWrittenKeys—if (hookWrittenKeys?.has(name)) continue;strictReadonlyWrites— opt-in per callERR_READONLY_FIELD_REJECTEDinstead of committing without the columngrep -rn strictReadonlyWrites src/ test/is empty, so drop-and-commit still describes this repoThe audit — field → writers → effective
runAs→ can it bereadonly?runAsis written effective: undeclared on a flow is'user', undeclared on a hook is'inherit'(soctx.apicarries the acting context).1.
src/objects/lead.object.ts—is_converted+ theconverted_*blockrunAsreadonly?lead_conversion→mark_convertedupdate_record'user'— norunAsdeclared; screen flow, and house rule 9 keeps it therelead.hook.ts)beforeInsertassignment toctx.input.dataVerdict: cannot be
readonly. ⛔ Stop condition hit — see below.2.
src/objects/quote.object.ts—subtotal/discount_amount/total_pricerunAsreadonly?quote_total_rollup(quote_line_item.hook.ts, after insert/update/delete)api.object('crm_quote').update(...)— cross-recordctx.api'inherit'→ the acting userquote_generation→create_quotecreate_record'user'revenue.seed.tsVerdict: 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.ts—approval_status(+approved_date)runAsreadonly?opportunity_approval→mark_approved/mark_rejectedupdate_record'system'— declared and argued in that fileopportunity_approval_on_create'system'— inherited via{ ...OpportunityApprovalFlow }approvalnode'sapprovalStatusFieldwrite'system'⭐ 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.ts—added_daterunAsreadonly?campaign_enrollment→create_campaign_membercreate_record'user'campaign_enrollment→create_contact_membercreate_record'user'marketing.seed.ts⭐ Verdict: it CAN honestly be declared
readonly: true. Filed as #1667.5 & 6.
src/objects/campaign.object.ts—actual_revenueand thenum_*blockThe defended set is
CAMPAIGN_METRIC_FIELDSincampaign.hook.ts.runAsreadonly?campaign_metrics_refreshcrm_campaignafterUpdate'inherit'→ acting usercampaign_attribution_refreshcrm_opportunityafter insert/update/delete'inherit'→ acting usercampaign_lead_conversion_refreshcrm_leadafterUpdate'inherit'→ acting usercampaign_member_metrics_refreshcrm_campaign_memberafter insert/update/delete'inherit'→ acting userAll four write
api.object('crm_campaign').update(...); none declaresHook.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 namingcampaign_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:177also 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.added_date: its writers are inserts, which the strip never reaches), the note says so outright instead of quietly keeping the old conclusion.#2948survives only where it still cites the right behaviour.opportunity.object.ts:139's already-precise statement is untouched, andtest/case-first-response.test.ts:75is out of scope as the card directs.⛔ Stop condition — hit, reported, not applied
Three sites could take
readonlyif a write were elevated:crm_lead.is_converted(elevatelead_conversion'smark_converted),crm_quotepricing and thecrm_campaignmetric block (declareHook.runAs: 'system'on the rollup/refresh hooks — the engine grewHOOK_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:
lead_conversionis the same shape Decision: shouldescalate_casedeclarerunAs: 'system'? It would let the three escalation flags becomereadonlyand delete a guard exemption — at the cost of raising a user-facing screen flow's privilege #1434 resolved: a screen flow that must keep running as the acting user, with one stamp that wants elevating. Splittingmark_convertedinto a dedicatedrunAs: 'system'subflow reached through asubflownode is exactly the shippedescalate_case→case-escalation-stamppattern, and house rule 9 already names it.crm_quotepricing and the campaign metrics are not that shape: their writer is a hook, not a flow, so the remedy would beHook.runAs: 'system'rather than a subflow. That is a broader grant — the hook's wholectx.apibecomes elevated, reads included — and rule 9's "elevate as little as possible" cuts against it without a maintainer ruling.⛔ Neither is applied here. No flow was touched; the diff is confined to
src/objects/*.object.ts.Verification
pnpm verify— fully green, exit 0.validatetypechecklintlint:i18n-gatei18n/missing-*issueshygienehygiene:tokensbuildtestOut of scope, filed
crm_opportunity.approval_status/approved_datecan honestly bereadonlycrm_campaign_member.added_datecan honestly bereadonlycampaign_snapshot_metricsretired, still named in four live filesreadonly-write-semantics.test.tsheader 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
readonlyfindings and the elevation question.🤖 Generated with Claude Code
https://claude.ai/code/session_018xtjdpZFjgWh4Ad9Wcx68J
Generated by Claude Code