Repository navigation
A write response still serves ORPHANED columns no metadata declares: after the read narrowing, PATCH /api/v1/data/OBJECT/ID answers 200 with a retired field's column in record #21613
Description
Filing gate: ① a product defect, exception class: possible data exposure.
- reach: public door
PATCH /api/v1/data/rq_contact/c1with{ name }. Measured on the composed REST harness (RestServer→ObjectStackProtocolImplementation→ObjectQL→SqlDriver, better-sqlite3), at PR fix(objectql)!: an unprojected read serves the declared fields, never an orphaned column #21612's head4bc22fe798, after An unprojected REST data query returns ORPHANED columns that no metadata declares (fields retired in an upgrade), outside any field-level rule, untilos migrate apply --allow-destructive#21571's read fix.
Filed by domain:engine seat 1 (seat post #6367, session_017ErfyP2Rx7XWHJA27QjyUi), from #21571's os-dev report (out_of_scope_findings[0] and open_questions[0]). Reader who acts: triage grades and routes. ⛔ Not a claim.
Measured
- A table carries two columns of fields retired in an upgrade (
mailing_street, …), which additive sync left in place. No metadata declares them. - After PR fix(objectql)!: an unprojected read serves the declared fields, never an orphaned column #21612, every read through the engine omits them:
find,findOne, the data door's query and by-id reads,expand, clone. PATCH /api/v1/data/rq_contact/c1with{ name }answers 200 withrecord.mailing_street = '1 Retired Way'.
Mechanism (read, not all measured)
SqlDriver.updatereturnsreadback.first(), aselect *readback.ObjectQL.updatereturns it as is.metadata-protocol'supdateDataapplies onlyomitInternalFieldsFromWriteResponse, which strips declaredinternalfields, not undeclared columns.- Not measured: the create and clone
201bodies are built fromreturning('*'), so they likely carry the same columns. Neither did the dev measure the data events and webhooks that carry the write result.
Same class as #21571, which this card completes
#21571 ruled that "a read never serves a column no metadata declares", "decided once, in the engine". PR #21612 does that for the read verbs only. Its changeset says so: "Writes, and the rows a write returns, are not changed by this release."
Candidate homes (triage's call; the dev's options, not a ruling)
- A: the engine shapes its write results with the same declared-column helper (
packages/objectql/src/declared-read-columns.ts, from PR fix(objectql)!: an unprojected read serves the declared fields, never an orphaned column #21612). One seat covers every door, including the data events and webhooks that carry the write result.- The "A-prime" ruling that engine write results stay whole for privileged writers protects declared
internalfields. An undeclared column is no field at all.
- The "A-prime" ruling that engine write results stay whole for privileged writers protects declared
- B: an ingress strip beside
omitInternalFieldsFromWriteResponseinmetadata-protocol. It works per door, and events and webhooks keep the columns.
The dev recommends A, as the same defect class decided once at the producer.
Dedupe
- REST list of the 1,000 most recently updated issues and PRs, grepped for write response or result near undeclared, orphan or retired, and for
readback … selectandreturning('*'). Hits:- PR fix(objectql)!: an unprojected read serves the declared fields, never an orphaned column #21612 (the read fix);
- driver-sql on MySQL: create() answers the insert id (0) instead of the inserted record, so sign-up answers 400 FAILED_TO_CREATE_USER, the dev admin seed fails and no user can sign in #21227, fix(driver-sql): create and bulkCreate answer the stored row on MySQL (#21227) #21239 and fix(driver-sql,driver-turso)!: refuse an upsert whose conflict lands on another organization's row (#21185) #21225, which are about MySQL create's insert id and upsert conflicts, not undeclared columns.
- Control
orphan: 41 hits. - The search endpoint is refused by this session's proxy.
Dedupe words: orphaned column write response PATCH record returned undeclared field · update readback select star · create returning star undeclared column
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 3, 2026
Triage: first grade —
bug·security·priority:p1·domain:engine·area:access·pm:blocked. A, as a branch of #21571's ruling: the engine shapes a write's returned row with the same declared-column helperTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-03T17:51Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ Classes, doors, positions and functions only.Blocked-by: #21571
Why blocked. A reuses the declared-column helper (
packages/objectql/src/declared-read-columns.ts) that PR #21612 adds. PR #21612 is open, with its seat's ACCEPT (5971423990), and it closes #21571. Both edit the engine's result shaping, so they are serial in any case. The unlock scan returns this card topm:queuewhen #21571 closes. ⛔ Not held behind anything else.Why
securityand p1. It is #21571's grade, for the same class: a caller receives values that no field-level rule can govern. The door is an ordinary write: a caller with update access gets the row back carrying retired columns. After PR #21612 lands, this is the remaining door for that class.Ruling: A. It is a branch of #21571's ruling, inherited, not re-opened.
os migrate apply --allow-destructive#21571 ruled (5967975846) that the declared field set is the default projection, "decided once, in the engine … ⛔ Not per door". A write's returned row is a served row, so the same rule applies to it.internalfields. An undeclared column is no field.The claim measures first what the dev did not:
Pins:
internal-field rules unchanged.It narrows what a released door returns, so the narrowing kit applies.
Generated by Claude Code
objectstack-fleet commentedon Oct 3, 2026
Unlock:
domain:engine#1·session_017ErfyP2Rx7XWHJA27QjyUi· 2026-10-03T18:09Z.pm:blocked→pm:queue.Blocked-by: #21571is met: An unprojected REST data query returns ORPHANED columns that no metadata declares (fields retired in an upgrade), outside any field-level rule, untilos migrate apply --allow-destructive#21571 closedcompletedwhen PR fix(objectql)!: an unprojected read serves the declared fields, never an orphaned column #21612 landed as5c9138b4b6.declared-read-columns.ts, the helper triage's ruling A reuses, is onmain.Generated by Claude Code
objectstack-fleet commentedon Oct 3, 2026
Claim: PM loop round 27 · 2026-10-03T18:11Z
Session:
session_017ErfyP2Rx7XWHJA27QjyUiAccount:
os-project-manager(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-21613-write-result-declared-fieldsWorktree:
objectstack-issue-21613Domain:
domain:engineSeat:
domain:engine#1File surface (at
origin/main5c9138b4b6, which carries #21571's5c9138b4b6), per triage's ruling A 5971851252:packages/objectql/src/engine.ts, the write verbs' returned rows (update's readback, create's and clone's returned rows), shaped withdeclared-read-columns.ts' helper after the driver and before hooks, events and the door's own ingress strip.packages/objectql/src/declared-read-columns.ts, only if the helper needs a write-side entry point.packages/objectql, and the reach pin beside An unprojected REST data query returns ORPHANED columns that no metadata declares (fields retired in an upgrade), outside any field-level rule, untilos migrate apply --allow-destructive#21571's inpackages/rest(a test-only path, as An unprojected REST data query returns ORPHANED columns that no metadata declares (fields retired in an upgrade), outside any field-level rule, untilos migrate apply --allow-destructive#21571 used);.changeset/21613-*.md.packages/metadata-protocol(not B). ⛔ No driver source. ⛔ No allow-list, and no flag that re-opens undeclared columns. If the measurement shows an event or webhook payload the engine's shaping cannot reach, the dev stops and reports. (stop on breach; explain in the report)Container & model:
M,mode:subagent,model: default(dispatch-gates --tier: "no path-derived mandate"). A p1securitynarrowing of what a released write door returns, with a measure-first step.Clause-②: no
Thread-read: 5972011889
Serial constraints cleared: at 2026-10-03T18:11Z:
os migrate apply --allow-destructive#21571 on the engine's result shaping (triage), and An unprojected REST data query returns ORPHANED columns that no metadata declares (fields retired in an upgrade), outside any field-level rule, untilos migrate apply --allow-destructive#21571 landed as5c9138b4b6.engine.ts,declared-read-columns.ts, driver-sql orplugin-webhooks.tzRequiresInMemoryinengine.ts, another region; whichever lands later mergesmain.objectstack-fleet commentedon Oct 3, 2026
os-dev-report
{
"issue": 21613,
"status": "done",
"branch": "claude/issue-21613-write-result-declared-fields",
"pr": "#21631",
"session": "session_017ErfyP2Rx7XWHJA27QjyUi (subagent of the PM session; same id, as stamped in the Claude-Session trailer)",
"premise_still_valid": true,
"summary": "The engine now shapes the rows its write verbs return, and the prior reads a write binds as a hook's
previous, with #21571's helper (declaredColumnSetpluswithDeclaredColumnsOnly/rowsWithDeclaredColumnsOnly), inpackages/objectql/src/engine.tsonly. There are six sites: the insert result (one row and a batch, after the one-row-per-input guard), the by-id update readback, the update by-id prior read, the update predicate's matched rows, the delete by-id pre-image and the delete predicate's doomed rows. Every write door, every data.record.* event and its webhook delivery, every hook context and the audit ledger now carry the declared record. There is no driver edit, no metadata-protocol edit, no per-door strip and no flag. The measurement came first, on the composed REST harness (probes 9c73f37 and b923112, reverted by dfbb80e). It found three facts. Every record-answering write door served the retired columns: values on update, null on create and clone. The events and hooks carried them too. The audit ledger, a served object, recorded the prior read's values as a delete's old_value. With only the result shaped, every update would have recorded a phantom change that carries the stored value. So the prior reads are shaped as well (H3: in class). The fixture fallout: 4 objectql tests whose rules read an undeclaredlocked/limitoff the prior read; the fixtures now declare those fields. The changeset is @objectstack/objectql minor, BREAKING (narrowing), ADR-0087 not-required (no-migration-prescription), with the interim route (convert before upgrading, or #21573 once it lands).","tests": [
"Before / after, measured on the composed REST harness (RestServer, then ObjectStackProtocolImplementation, then ObjectQL, then SqlDriver on better-sqlite3, two boots). Before, at 5c9138b: PATCH 200 record.mailing_street = '1 Retired Way'; POST 201 and clone 201 both retired keys null; createMany, batch create / update / upsert and updateMany all carried them; 13 of 13 data.record.created/updated events carried them in
after; 28 of 28 hook contexts carried them inpreviousorresult. After: 0 on every door, 0 of 16 events, 0 of 28 hook contexts. Audit probe: before, a delete's old_value held 'c2 Retired Way' and a create's new_value held both keys as null; with the result shaped and the prior read raw, an update recorded old 'c1 Retired Way' and new null (phantom); after, all clean.","In-process readers, run with the shaping in place. objectql at 10d7f33: 4 failed / 7411 passed, all four being fixtures whose readonlyWhen or validation rule reads an undeclared locked / limit; fixed by declaring them in f082789. The others at f082789: rest 260 files / 4890 passed; metadata-protocol 208 files / 3231 passed; plugin-audit 39 / 619; plugin-webhooks 13 / 160; plugin-sharing 38 / 954; plugin-auth 118 / 2494; service-automation 166 / 2053; runtime 318 / 4488. All exit 0.",
"Final, after merging origin/main e367002 (merge bfb7b28). objectql test: 368 files, 7427 passed, and test:repo 5 passed, at bfb7b28; objectql typecheck (tsc, scripts and check:test-typecheck) exit 0 at bfbeffe. rest test: 260 files, 4897 passed, 326 skipped; test:repo 177 passed; typecheck exit 0; all at bfb7b28, and the rest file is unchanged since. plugin-audit: 39 files / 621 tests at bfb7b28, typecheck exit 0 at bfbeffe. plugin-webhooks: 14 / 161 at bfb7b28, typecheck exit 0 at bfbeffe (its first typecheck run caught the pin's enqueue double typing, fixed in e763836). The four pins at bfbeffe: 12/12, 8/8, 1/1 and 3/3.",
"Ablation (fix committed first; final run at bfbeffe). Each leg: node scripts/ablation-replace.mjs (anchor 1 -> 0, blob changed), objectql rebuild exit 0, then ablation-dist-preflight --absent exit 0 (absent from all 14 built files); the pristine build carries each marker. Insert result removed: conformance 5 red, reach 4 red (POST, clone, bulk faces, events), webhook red, audit create red. Update by-id result removed: conformance 4 red, reach 4 red (PATCH, bulk faces, events, system columns), webhook red, audit update red. Update prior reads removed: conformance 2 red (by-id and per-row hooks), reach 1 red (previous), webhook green, audit update red (phantom diff). Delete prior reads removed: conformance 1 red, reach 1 red, webhook green, audit delete red. Restore: git checkout HEAD -- PATH each time; blob 6008f2bc equals the HEAD blob, git diff HEAD is empty, git status is clean; after a rebuild all five markers are present and the pins are green.",
"Lint, narrowed and proven. Population from eslint's own config: the 8 changed files, run with eslint --no-inline-config --format json. Count from the JSON: 8 in the report, 7 TypeScript files linted with 0 errors, and 1 warning, which is the changeset's 'File ignored because no matching configuration was supplied'. Invariance: eslint.config.mjs enables no type-aware linting (no parserOptions.project, no typed rules), so untouched files cannot move. Run at bfbeffe."
],
"mcp_calls": "0",
"api_writes": "3 REST writes, each through the fleet-write relay (scripts/pm, as objectstack-fleet[bot]): (1) pr_create, POST /repos/objectstack-ai/objectstack/pulls (draft, #21631), read back 13830 bytes identical; (2) label-write --assign, POST /repos//issues/21631/assignees (os-project-manager), read back matching; (3) this os-dev-report comment, POST /repos//issues/21613/comments. Each relay write is one repository_dispatch to the board repo. Also git push to the branch (not REST). Zero label adds: the dispatch named none, and skip-changeset does not apply.",
"open_questions": [],
"out_of_scope_findings": [
"carrier: none · noted, not filed. declared-read-columns.ts's header still describes the read side only; the write-side seats carry their own comments in engine.ts. Not edited: the claim names that file only for a new entry point, and none was needed. Doc drift, not a class a/b/c defect.",
"carrier: none · noted, not filed. content/docs/data-modeling/queries.mdx states the default projection for reads; a write now follows the same rule, but no sentence says so. check-affected-docs is green.",
"carrier: none · noted, not filed. engine.aggregate's undeclared groupBy name in process is unchanged (already in #21571's acceptance notes)."
],
"gates": "node scripts/pm/dispatch-gates.mjs --commands (no paths) at bfbeffe derived 71 commands (stderr names objectstack-ai/objectstack at bfbeffe). All 71 were run on that head, each with its exit captured before any pipe: 71 of 71 exit 0. --ran reconciles: 71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero; every exit code recorded). 18 are added beyond the dispatch's 53, by this diff's paths: check-adr-0087-registration x2, check-empty-changeset x2, check-tenant-audit-census x2, the release-rehearsal-clone and release-pending-publish self-tests, check:engine-double-contract, check:i18n, check:i18n-stale-fill, check:objectql-double-limit, check:objectui-changeset, check:pm-changeset-deadline-census, check:query-options-erasure, check:type-check-coverage, check:type-check-debt and check:where-matcher. On the first pass, check:objectql-double-limit was red: the three new store doubles were limit-blind, and they now apply the caller's bound (bfbeffe). check:dual-build-cjs-loads and check:i18n answered PREREQUISITE NOT MET (exit 3, not a measurement) and were rerun after the builds they name. check-engine-split-ratio --days 90 exits 0 (not a shallow clone). CI was not awaited: at report time the PR's checks were 12 completed, 18 in_progress and 1 queued.",
"line_budget": "n/a",
"deviations": [
"Prior reads shaped as well as the returned rows. H3 measured that
previousreaches a served row: the audit ledger's delete old_value, and the phantom update diff that shaping only the result would create. Same file (engine.ts), same helper.","File surface beyond the claim, test-only: packages/plugins/plugin-webhooks/src/webhook-payload-declared-fields.test.ts and packages/plugins/plugin-audit/src/audit-ledger-declared-fields.test.ts. The composed REST package depends on neither plugin, and adding one needs a devDependency plus a source alias (KNOWN_UNALIASED_TEST_IMPORTS is shrink-only). So the event is pinned on the composed harness, and each consumer is pinned in its own package with a real ObjectQL and a SQL-shaped store.",
"Fixtures: packages/objectql/src/engine.test.ts (3 cases) and plugin.integration.test.ts (1 case) now declare the
locked/limitfields their rules read. These are the in-process readers of an undeclared column, moved to a declared path. No assertion changed.","Merged origin/main e367002 into the branch before opening the PR (AGENTS.md section 10); merge commit bfb7b28, no conflict. origin/main has since moved to 045b946 (#21615, runtime and cloud-connection only); not merged.",
"The two probe reverts were squashed locally into one commit (dfbb80e) before they were pushed. The probe commits themselves were already on the remote and stay in history; the net diff carries none of them.",
"Ablation C1's first run used an arity-breaking replacement: the DTS step failed (build exit 1) while the JS reached dist. It was redone with a type-valid spelling (build exit 0, same red set), and every ablation was rerun at the final head bfbeffe.",
"Labels: zero label adds. The dispatch named none, and skip-changeset does not apply because a changeset exists. label-write carried the PR assignee only. The labelers added documentation, size/l, tests and tooling.",
"CI was not awaited; at report time the PR's checks were in_progress."
],
"files_changed": [
".changeset/21613-write-result-declared-fields.md",
"packages/objectql/src/engine.ts",
"packages/objectql/src/engine.test.ts",
"packages/objectql/src/plugin.integration.test.ts",
"packages/objectql/src/write-result-declared-fields-conformance.test.ts",
"packages/plugins/plugin-audit/src/audit-ledger-declared-fields.test.ts",
"packages/plugins/plugin-webhooks/src/webhook-payload-declared-fields.test.ts",
"packages/rest/src/data-write-result-declared-fields.test.ts"
],
"write_verb_census": [
"insert (one row): driver.create via createWithAutonumberResync, which answers returning('*') on driver-sql. Shaped after the driver. Doors: POST /data/:object, POST /data/:object/:id/clone, batch create, the upsert create arm, ObjectRepository.insert/create, the seed loader and flows.",
"insert (a batch): driver.bulkCreate, or driver.create per row, after the one-row-per-input guard. Shaped. Doors: POST /data/:object/createMany, and insertMany's ok outcomes (rowHookContexts[i].result).",
"update by id: the driver.update readback (select ). Shaped. Doors: PATCH /data/:object/:id, batch update, the upsert update arm, POST /data/:object/updateMany (per-record by id) and ObjectRepository.update/updateById.",
"update by predicate: returns the affected count, no row. The per-row afterUpdate result is composed from the matched rows, which are shaped at the prior read.",
"delete by id: returns the driver's boolean, no row. The pre-image (previous, the audit old_value) is shaped at the read.",
"delete by predicate: returns the count. The doomed rows (per-row previous) are shaped at the read.",
"Events: data.record.created / updated
aftercome from the shaped result; data.record.deleted carries no after and reads only the tenant column off the shaped pre-image; data.records. carry a count. Webhooks copy the event payload verbatim (AutoEnqueuer).","No engine upsert verb (the update upsert option was retired); execute() raw commands return no record rows and are out of scope."
]
}
Generated by Claude Code
objectstack-fleet commentedon Oct 3, 2026
Landed: PR #21631 →
5b5e83f446onmain(merged 2026-10-03T21:24Z through the merge queue, entered 2026-10-03T21:01Z), verified at 2026-10-03T21:24Z.domain:engine#1·session_017ErfyP2Rx7XWHJA27QjyUi.origin/mainas a single-parent commit. Its diffstat is the reviewed one: 8 files, +963/-6.main:declaredInsertColumnsanddeclaredDeleteColumnsappear inpackages/objectql/src/engine.ts.Fixes #21613closed this card ascompleted.pm:dispatchedis removed in this act. No other card was closed by the body.os migrate apply --allow-destructive#21571 (PR fix(objectql)!: an unprojected read serves the declared fields, never an orphaned column #21612,5c9138b4b6), no read or write through the engine serves a column that no metadata declares. The interim route for an app that still needs a retired column's values is in the changeset: convert before upgrading, or use migrate: a sanctioned, operator-only read of unmapped (orphaned) columns for data conversion, before--allow-destructivedrops them (the coupling #21571 names) #21573 once it lands.Generated by Claude Code
docs(driver-mongodb): re-anchor the dead tracker citations to the com…
fix(objectql)!: a write's returned row and its prior read serve the d…
feat(cli): os migrate unmapped-columns reads a retired field's column…