Skip to content

fix(rest)!: the CSV export writes a text cell in a form its consumer reads as text, and the CSV import reverses it (#25078) - #25130

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-25078-export-value-context
Oct 22, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-25078-export-value-context

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #25078

Clause-②: no (narrowing)
Metadata-protocol: none

The CSV export now writes a text cell its consumer would interpret in a form that consumer reads as text, and the CSV import reverses exactly that. This is this repository's half of the maintainer's choice B on #25078 (comment 6319255545): the rule is defined once, server-side, and both directions read it.

Both landing prerequisites are on main: objectstack-ai/objectui#13316, merged as 56cf4d8e89, and this repository's .objectui-sha bump over it, PR #25160, merged as 06900c9c2.

Contract

Served answers that change

  • GET /api/v1/data/:object/export?format=csv, on the metadata path and on the raw path (no field metadata). A CSV text cell is served with one apostrophe (U+0027) in front of its content, before the RFC-4180 quoting, when both of these hold:
    • its first character is one of a small fixed set that the file's consumer interprets;
    • it is not made only of digits, spaces and the characters a signed number or a phone number is written with.
    • A text cell is a string in a column whose field is not declared numeric, boolean, date or datetime. The declared type decides, never the JS type. Header labels and reference display titles are text cells too. On the raw path every string cell is one.
    • Every other byte of the file is unchanged.
  • parseCsvToRows, exported from @objectstack/rest, and through it the CSV payload of three doors:
    • POST /api/v1/data/:object/import;
    • POST /api/v1/data/:object/import/jobs;
    • POST /api/v1/auth/admin/import-users.
    • Every cell, header row included, loses exactly one leading apostrophe when the rest of the cell is one the export would mark. Cells without that shape are unchanged.
    • The reversal runs at the parse, before an inline or a named mapping, and it reads the cell text only. With a named mapping, rows keep their source headers until core's applyMappingToRows, so no target field type is known at that point.

Unchanged by construction: phone numbers and signed amounts; every cell that does not begin with one of the set; numeric, boolean, date and datetime cells; the xlsx and json export formats; json and xlsx import payloads.

Stated consequences

  • A user-authored CSV cell that is an apostrophe followed by a cell the export would mark now imports without that apostrophe. A cell that begins with an apostrophe followed by anything else imports unchanged.
  • A stored text value of that same shape is written unchanged by the export, so it comes back without its leading apostrophe. Measured with a one-shot script against this branch: it is the one stored shape the round trip does not return unchanged. The maintainer kept the rule as chosen (ruling record 6328219098 on the card).
  • A value beginning with TAB or CR is marked, and the import removes the mark. Core's default trimWhitespace: true then trims the leading TAB or CR, exactly as it did before this change. With trimWhitespace: false the value returns exactly. Both cases are pinned in csv-text-cell-round-trip.test.ts.

The rule lives in packages/rest/src/export-format.ts (csvTextCellNeedsMark, markCsvTextCell, unmarkCsvTextCell, isCsvTextColumn). The writer is formatCsvCell / rowsToCsv in rest-server.ts. The reversal is in parseCsvToRows in import-prepare.ts.

Files

  • packages/rest/src/export-format.ts: the rule, defined once. The set of non-text types is composed from the spec's NUMERIC_VALUE_TYPES, BOOLEAN_VALUE_TYPES, CALENDAR_DATE_TYPES and INSTANT_TYPES, not re-listed.
  • packages/rest/src/rest-server.ts: formatCsvCell takes a text-column flag, and rowsToCsv derives that flag per column from the declared type.
  • packages/rest/src/import-prepare.ts: parseCsvToRows reverses every cell, and reverses header cells before trimming and mapping.
  • packages/rest/src/data-action-routes.ts: comment only. The export route's doc said the raw path streams stored values unchanged, which this change makes false for csv.
  • Pins:
    • packages/rest/src/csv-text-cell.test.ts (new);
    • packages/rest/src/csv-text-cell-round-trip.test.ts (new);
    • one new describe in packages/plugins/plugin-auth/src/admin-import-users.test.ts, on that file's existing engine double, so the engine-double ledger does not move.
  • .changeset/25078-csv-text-cell.md: @objectstack/rest patch. It carries the BREAKING banner and one ADR-0087 marker, not-required (no-migration-prescription). Its "What to do" line points at the rule in this repository's packages/rest/src/export-format.ts, not at a function name the package does not publish.

Tests

The tables and the ablations below were measured at bf8244cb20. The suites were run again on the merged head 2e4476001.

  • The predicate: one table of 56 cases, with the boundary characters on both halves of the rule.
    • The code-point neighbours of each trigger as a first character.
    • Every character of the numeric-text set, after each trigger character the rule's second half applies to.
    • A letter or a trailing word outside that set.
  • The writer table, per value, on the metadata path and on the raw path:
    • plain words after a trigger character are marked;
    • two phone numbers, a signed amount, a company name and an unsigned amount are byte-identical;
    • number, currency and percent fields holding a negative amount stay numeric and unprefixed;
    • a string in a numeric, boolean, date or datetime column is never marked;
    • header labels and reference titles that start with a trigger character are marked.
  • The round trip, through each CSV door:
    • the sync import, with an inline mapping and with a named mapping;
    • the async import job;
    • the admin user import, whose file comes from the real export route of RestServer.
    • Each case asserts that the file carried the marks, and that every stored value came back exactly, including a value that begins with an apostrophe followed by a plain word.
  • Suites, at 2e4476001:
    • This PR's pins: csv-text-cell.test.ts and csv-text-cell-round-trip.test.ts, 2 files / 133 tests passed; admin-import-users.test.ts, 41 of 41 passed.
    • @objectstack/rest local project: 314 files / 6318 tests passed, 326 skipped.
    • @objectstack/plugin-auth: 183 files / 3464 tests passed, 10 skipped.
    • Both packages' typecheck, the test layer included, exit 0.
    • @objectstack/dogfood (the full battery, owed by the ! title): 287 files passed and 3 skipped; 2395 tests passed and 18 skipped.

Ablations, measured at bf8244cb20 and not run again: this round changes no tested behaviour. Each leg goes through scripts/ablation-replace.mjs. The anchor must hit, and the restore is proven by blob equal to HEAD and an empty git diff HEAD. The whole tree's porcelain is empty after every leg.

  • Writer ablated (formatCsvCell never marks): 20 red of 133.
  • Header marking ablated: 5 red.
  • Declared-type decision ablated (isCsvTextColumn always true): 12 red.
  • The predicate's second half removed: 36 red.
  • Cell reversal removed: 14 red.
  • Header reversal removed: 6 red.
  • Admin door, which resolves @objectstack/rest through dist/:
    • The reversal was removed and rest was rebuilt. ablation-dist-preflight then reported the marker absent from all 6 built files, and the admin round trip went red, 1 of 1.
    • After the restore and a rebuild, the preflight reported the marker present in 2 built files and a clean tree, and the pin was green again.

Gates

All readings are on the merged head 2e4476001, with each exit code captured before any pipe.

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 71 commands from the 8 changed paths against the merge base 06900c9c2. All 71 exited 0 in one run, after a full workspace build.
  • The reconciliation printed: ✓ dispatch-gates --ran: 71 derived famil(ies) accounted for — 71 run, 0 NOT-MEASURED (a DERIVED zero — all 71 recorded an exit code and none of them is 3).
  • node scripts/check-changeset-no-major.mjs --base origin/main --event with this body: exit 0, LEVEL AXIS ... a non-protocol change at patch.
  • The families the derivation declares too wide to place, and pnpm lint, are CI's runs and were not run locally.
  • CI runs the full farm.

main at fccbd9d0d5 was merged once into this branch. The incoming commits touch no file this PR edits. One of them adds the NOT NULL tenant column, and that is why the round-trip harness calls installDefaultOrganization.

main at 06900c9c2 was then merged for landing, with no conflict. Of the files this PR edits, it changes only rest-server.ts, and only comments there (PR #25124). The pre-landing round, which is that merge and the changeset's "What to do" line, is by session session_01AFrKwndmrCLtF5V9hNtrw3.

Acceptance notes

  • The changeset level is patch, with Metadata-protocol: none. That follows AGENTS.md Post-Task Checklist step 3 as it reads on main since f2b6cf230b: a non-protocol change, widening or breaking, ships patch and keeps its BREAKING banner and its ADR-0087 disposition.
  • For import wizard: send a CSV file's own CSV to the server's CSV import door (format csv) on every path, so the server applies the one cell rule its CSV export pairs with (objectstack#25078) objectui#13315: an inline mapping sent with CSV text must name a column by its header as this parse reads it, that is after the reversal. This only matters for a column whose label begins with one of the set. At the pin 56cf4d8e89, the wizard sends no inline mapping. Its hand-mapped rows are keyed by field name in the browser, and a named mapping travels by name (buildImportRequest in ImportWizard.tsx, read with git show).
  • The round-trip harness calls installDefaultOrganization. The async job door's sys_import_job write needs the Default Organization on the NOT NULL tenant column that landed on main in 5e9b100743. Without that call one test is red, measured.

Generated by Claude Code

…reads as text; the CSV import reverses it (#25078)

The CSV writer marks a text cell its consumer would interpret, by one rule
defined once in export-format.ts and keyed on the field's declared type; the
CSV import parse removes exactly that mark, so an export to import round trip
through the sync, async-job and admin user import doors returns the stored
value. xlsx, json and the json import door are unchanged.

Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6
Co-authored-by: Claude <noreply@anthropic.com>
…s existing engine double (#25078)

Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6
Co-authored-by: Claude <noreply@anthropic.com>
…not return unchanged (#25078)

Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6
Co-authored-by: Claude <noreply@anthropic.com>
…port-value-context

Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6
Co-authored-by: Claude <noreply@anthropic.com>
…the tenant column needs (#25078)

Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 21, 2026
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

12 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 19 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 06900c9c2372973618eb6b5ad72c9d443e6d7c55 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from f1b98d3e8193744c04fffc795ad601d9d299402c — the merge of head 2e4476001a37b95f99294d9ddcf1a3dac5742917 into base 06900c9c2372973618eb6b5ad72c9d443e6d7c55, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f1b98d3e8193744c04fffc795ad601d9d299402c && git checkout f1b98d3e8193744c04fffc795ad601d9d299402c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 06900c9c2372973618eb6b5ad72c9d443e6d7c55 2e4476001a37b95f99294d9ddcf1a3dac5742917 && git checkout -B drift-repro 06900c9c2372973618eb6b5ad72c9d443e6d7c55 && git merge --no-ff 2e4476001a37b95f99294d9ddcf1a3dac5742917

node scripts/docs-audit/affected-docs.mjs --json 06900c9c2372973618eb6b5ad72c9d443e6d7c55

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: bf8244cb20dcaa155853344a15baad129cef7e0e
Local-runs: none
Scope: packages/rest/src/export-format.ts, packages/rest/src/rest-server.ts, packages/rest/src/import-prepare.ts, packages/rest/src/data-action-routes.ts, packages/rest/src/csv-text-cell.test.ts, packages/rest/src/csv-text-cell-round-trip.test.ts, packages/plugins/plugin-auth/src/admin-import-users.test.ts + packages/spec/src/data/field-value.zod.ts, packages/spec/src/data/index.ts, packages/core/src/utils/import-field-meta.ts, packages/core/src/utils/import-mapping.ts, packages/core/src/utils/import-coerce.ts, packages/plugins/plugin-auth/src/admin-import-users.ts, packages/rest/src/index.ts, packages/rest/package.json, packages/rest/src/default-organization.testkit.ts, .objectui-sha, content/docs/references/api/export.mdx, AGENTS.md (Post-Task Checklist step 3 as it stands on main 4e2c42f363); the prose face .changeset/25078-csv-text-cell.md is judged in ②.

① Derived judgments

Read on the net diff of bf8244cb20 against its merge base with main (fccbd9d0d5; main has since moved to 4e2c42f363 without touching any file this PR edits): 8 files, +740/−10. Inputs: card #25078's body and all ten comments, the PR body and file list, the head's check-runs.

  1. The rule, defined once — right. csvTextCellNeedsMark (export-format.ts) is the only definition: the text's first character is one of the six leads AND the text holds at least one character outside the numeric-text set (digits, the space, and the six punctuation marks a signed amount with a group separator or a phone number with parentheses is written with; the first character counts). markCsvTextCell prefixes one mark (U+0027); unmarkCsvTextCell removes exactly one mark when and only when the remainder satisfies the predicate. No second copy of the predicate exists under packages/ outside the pins (git grep at the head). The character class is a literal set with one digit range and an escaped hyphen, so it holds exactly those seventeen characters and nothing else.

    • The 56-case table — no boundary mis-sorted. Counted: 6 leads before a plain word; 6 leads alone (the four that are themselves outside the set marked, the two signs alone not); 9 + 9 set members after each sign; 5 outside-set cases; 8 ordinary business values including the empty cell; 11 code-point neighbours of the six leads (both sides of each, the neighbour the two signs share counted once), every one unmarked exactly as the code sorts it; a leading space and the mark itself as first character, unmarked. Each expected value agrees with the predicate as written, and the table is round-tripped through mark then unmark for every row.
  2. Served answer GET /api/v1/data/:object/export?format=csv — right, and exactly as the PR body states it. rowsToCsv derives textColumns once per export from isCsvTextColumn(metaMap.get(f)); formatCsvCell(value, textCell) marks a value only when it is a string AND the column is a text column, before the RFC-4180 quoting (/[",\r\n]/), so the quote wraps the mark. The header row is marked with the flag fixed true; a reference display title and an option label arrive from formatRowCells as strings in a text column and are marked by the same rule. The raw path: the route starts from an empty map and the schema-read catch leaves it empty, so every column is a text column and every string cell goes through the rule. rowsToCsv has two call sites (data rows; the zero-row header), both in this route; no other CSV writer exists in packages/rest/src.

  3. Text columns by declared type, never by JS typeof — right. CSV_NON_TEXT_TYPES is composed from the spec's NUMERIC_VALUE_TYPES (number, currency, percent, rating, slider, progress, summary), BOOLEAN_VALUE_TYPES (boolean, toggle), CALENDAR_DATE_TYPES (date) and INSTANT_TYPES (datetime), reached through packages/spec/src/data/index.ts; packages/spec/** is untouched. A string in any of those columns is never marked (pinned for six types); a number, boolean, bigint, Date or serialised object is never marked in any column (the typeof string arm is the only one that calls the rule). time, formula, autonumber, json, the option and reference types are text columns, and a string cell in them is governed by the predicate alone.

  4. xlsx and json export — byte-unchanged, right. The xlsx arm writes headerLabel and formatRowCells straight into the worksheet; the json arm writes formatRowForJson. Neither calls rowsToCsv, formatCsvCell or the rule, and the diff touches neither arm nor formatCellValue, formatRowCells or formatRowForJson.

  5. parseCsvToRows (published from packages/rest/src/index.ts) — right. Every data cell is read through unmarkCsvTextCell; each header cell is unmarked BEFORE .trim() and before the inline mapping[h] lookup, so an inline mapping names the label as the export labelled it (pinned). The reversal reads the cell text alone and sits before any mapping: the inline rename happens inside this parse by header; a named mapping's applyMappingToRows (core) runs after the parse and reads row[source] by that unmarked header (pinned through the sync door with a named mapping whose source label begins with a lead).

    • All three CSV doors — right. prepareImportRequest's CSV branch is the only caller of parseCsvToRows; the sync door (data-action-routes.ts about 369), the async import-job door (about 483) and plugin-auth's runAdminImportUsers (admin-import-users.ts 550, which forwards the csv body) all parse through it. The json branches (format: 'json' with rows, a bare array) call only the inline rename; the xlsx branch calls parseXlsxToRows. Both untouched: the JSON and xlsx import doors read no mark.
  6. Round trip — every pinned value returns exactly, right. Ten values (four marked, five ordinary business values, one user-authored cell that is the mark before a plain word) through the sync door with an inline mapping, the sync door with a named mapping and the async job door (real routes over ObjectQL with better-sqlite3 in memory), and ten names through the admin door whose file comes from the real export route; each case first asserts the file carried the marks on cells and on the header label that begins with a lead, so none passes by the writer never marking.

    • TAB and CR leaders — stated honestly. Marked by the writer; a CR cell was RFC-4180-quoted before this change too, so the parse keeps it; unmarkCsvTextCell restores the leading character; core's text fall-through (import-coerce.ts) then trims under the default trimWhitespace, which is what it did to the same two values before this change. With trimWhitespace: false the stored value returns exactly. Both pinned in csv-text-cell-round-trip.test.ts; both stated in the changeset and the PR body.
    • The one stored shape — stated honestly. A stored text value that is one mark followed by a remainder satisfying the predicate: markCsvTextCell leaves it (its first character is the mark, not a lead), the parse removes the mark, so it does not return. The changeset, the PR body and the unmarkCsvTextCell docblock all state it; the dev measured it one-shot and the reading here agrees. A stored value with two leading marks before such a remainder does round-trip (after one mark the remainder begins with the mark, not a lead), and the parse pin fixes that case, so "that shape" in the changeset is exactly one shape.
  7. Public surface of @objectstack/rest — right: nothing added, one published behaviour changed and declared. parseCsvToRows is the only published name whose behaviour moves, and the PR body declares it. The five new exports of export-format.ts are not re-exported by index.ts, the package's exports map serves only . from dist/, and files ships dist only, so they are module-internal; formatCsvCell is module-private; rowsToCsv keeps its signature and is not published. The CSV doors' accept set is unchanged (every cell they accepted they still accept); what they store for one cell shape changes, which is the narrowing ② records.

  8. data-action-routes.ts — comment only, right. The eight added and one removed lines are the route docblock; the sentence it corrects ("the raw stored values stream through unchanged") would have been false for csv after this diff. No doc under content/docs or docs/ states the cell-level contract (references/api/export.mdx describes the payload shape and trimWhitespace only), so the claim's "docs the change makes false" resolves to that one comment; Flag docs affected by code changes concluded success.

  9. Landing hold — right. Blocked-by: objectstack-ai/objectui#13315 is line-initial in the PR body; the PR is a draft; .objectui-sha is not in the file list and its blob at the head equals main's (cf153eb2e5).

  10. Control bytes — none. TAB, CR and the mark are spelled as escapes or by code point in all eight files at the head (grep over the committed blobs: 0 hits in each).

  11. Check-runs on bf8244cb20 (read 2026-10-21, latest run per name, 40 names): 28 success, 7 skipped, 5 in_progress. Success includes Build Core, TypeScript Type Check and its four lanes, Lint & Repo Gates and its lanes, Check Changeset, Governed Surface Queue Guard, Test Core (1/6), and the claim and single-writer gates. Test Core (2/6) through (6/6) are still in_progress: the required Test Core context is NOT MEASURED, not a pass. This record judges on the diff; the seat that adopts it re-reads the head's check-runs and lands nothing until all six shards conclude success. Skipped: Console Pin Gate, Temporal Conformance, Build Docs, the dogfood matrix and Verify CLI, the two opt-in lanes (path-scheduled; not a verdict either way).

② Semver level

  • @objectstack/rest: patch — matches what the diff publishes. AGENTS.md Post-Task Checklist step 3 as it stands on main (4e2c42f363): the level follows the Metadata-protocol kind alone, none takes patch, and "a non-protocol change, widening or breaking, ships patch and keeps its BREAKING banner and ADR-0087 disposition". The changeset carries the BREAKING banner, exactly one ADR-0087 marker (not-required (no-migration-prescription), in the body's HTML-comment form, with its reasoning), and both line-initial declarations. The dispatch order's "BREAKING shipped as minor" was the pre-f2b6cf230b rule; the dev's deviation to patch is the rule on main. Check Changeset concluded success on this head. The package is released (18.0.0, files: dist), so a changeset is owed and skip-changeset would be wrong.
  • Clause-②: no (narrowing) — right, on the PR body and the changeset, line-initial in both. no: no new key on any published payload and no new published name (①.7). (narrowing): the CSV export serves different bytes for one class of text cells, and the CSV doors store a user-authored cell of one shape without its leading mark where they stored it verbatim before — BREAKING for a consumer that reads the file other than through these doors, and exactly the arm step 3's closed pair admits. Step 3's migration clause is not owed: nothing an author can write is removed or renamed, so no FROM → TO mapping; the ADR-0087 marker's reasoning (no metadata, declaration or stored shape moves) is correct on the diff.
  • Metadata-protocol: none — right. No spec key, accepted value, metadata type, meaning or default moves; packages/spec/** is untouched; the declared-type decision only reads the spec's existing classes.
  • Level of the prose. The changeset describes the change at the PR's own level (the lead set is "a small fixed set that its consumer interprets", the mark is named by code point, the one lossy shape and the TAB/CR trim are stated, the unchanged formats and doors are listed) and its title line carries fix(rest)!:. One imprecision: "What to do" tells an outside reader to apply csvTextCellNeedsMark (packages/rest/src/export-format.ts), but that name is not published by the package (①.7) and src/ does not ship, so the pointer is to repository source. Flagged in ③; not a level question.

③ Boundary flags

  1. Open question (report 6322329836): keep the rule as chosen (A) or extend it to an escape over leading marks (B). The maintainer decides; the seat puts it to them. It does not block this record. The rule under review is the one the maintainer chose (6317629287, the option labelled A′), the lossy shape is its direct consequence, and the PR states it in three places (①.6). The dev's recommendation of A is accurately argued: no producer of that stored shape was measured, B serves an unmeasured case, and B would not touch the objectui half. If the maintainer chooses B, the predicate and the pins change — a new head and a new record, before the hold lifts either way.
  2. The numeric-text set's membership against the private order — escalated to the seat. The inputs this record may read describe the set only as "digits, spaces and the characters a signed number or a phone number is written with" (PR body, changeset); the code's set holds digits, the space and six punctuation marks, and the table pins each of the six unmarked after a sign. The maintainer's exact set lives in the dispatch order, which this record did not read, and the reviewer's brief counts five punctuation marks (a loose count with the parentheses as one, or a different set). The seat holding the order compares before adopting: a set there that differs from the code's makes this verdict FAIL on the first judge-with-care item, since the rule would then not be exactly the maintainer's choice.
  3. Dev deviations (report 6322329836), each answered:
    • changeset level patch against the dispatch's minor — right, step 3 on main governs (②);
    • AGENTS.md read first at 795ff708d8 then at 03966ceae0, differing only in step 3 — right, and that step was read on the newer tree;
    • worktree on the existing branch, --ff-only, the fast-forward pushed as the write probe — process only, no diff effect; accepted;
    • origin/main fccbd9d0d5 merged once; installDefaultOrganization added to the harness for the NOT NULL tenant column — test-only; the testkit exists at the head (packages/rest/src/default-organization.testkit.ts); accepted;
    • two edits outside the round-25 claim's named surface: data-action-routes.ts comment-only (①.8) and the admin pin in plugin-auth — the claim's "pins cover all three doors" names that door, and its pin belongs in the door's own package; accepted;
    • the admin pin moved onto the file's existing engine double so the engine-double ledger does not move — no ledger file in the diff; accepted;
    • the mark spelled by code point after the Write tool turned an escape into a literal — no raw control byte in any changed file at the head (①.10); accepted;
    • first gate run exit 3 (built output missing) then exit 0; check-changeset-no-major driven with a synthesized payload; long runs under nohup — the head's CI runs are the gate readings, not these (①.11); accepted.
  4. Out-of-scope note for import wizard: send a CSV file's own CSV to the server's CSV import door (format csv) on every path, so the server applies the one cell rule its CSV export pairs with (objectstack#25078) objectui#13315 (noted, not filed — right not to file here): an inline mapping sent with CSV text must key a column by its header as this parse reads it, after the reversal — confirmed in the code (unmarkCsvTextCell(h).trim() precedes mapping[h]). It matters only for a label beginning with a lead and belongs to the objectui PR's author; the seat carries it there.
  5. The changeset's "What to do" names an unpublished function (②). A consumer following it reads this repository's source at the path the changeset names; the rule is otherwise stated in words at the disclosure level. The seat decides whether to reword it as the predicate in this repository's packages/rest/src/export-format.ts before landing (a prose-only push, which re-opens this record on a new head); publishing the predicate would be a surface widening and a different claim. Not blocking.
  6. Check-runs incomplete (①.11): five Test Core shards in_progress at the time of this record; NOT MEASURED, never a pass. Adopt only on a re-read with the required contexts concluded.
  7. The PR body counts plugin-auth at 181 test files and the report at 180 for the same local run; immaterial, the CI reading is the gate.

Implemented-by: claude/issue-25078-export-value-context
Reviewed-by: session_01LmjU2x1faLZ39DVTFyVca6

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT (landing held)

domain:cli seat 2 (#22648) · os-justin · session session_01LmjU2x1faLZ39DVTFyVca6 · 2026-10-21T17:49Z · head bf8244cb20dcaa155853344a15baad129cef7e0e · claim 6311298793 (amended 6313433222, 6317629287, 6319726645) → report 6322329836 → review of record 6322695804 (CONTRACT_REVIEW_TIER, PASS).

Check Reading
Shape Draft, base main. First line Fixes #25078. Clause-②: no (narrowing), Metadata-protocol: none and Blocked-by: objectstack-ai/objectui#13315 are line-initial. One changeset, @objectstack/rest: patch, with the BREAKING banner and one ADR-0087 marker. .objectui-sha is untouched.
The rule The record's ③ 2 escalation is answered here. The seat compared the code against its dispatch order (round 25), and the two match exactly: the leads are the six named; the "numeric text" set is the ten digits, the space and the six marks + - ( ) . ,, which is exactly /^[0-9 +\-().,]*$/; the trigger requires both halves; and one mark comes off on import only when the remainder satisfies the trigger. The brief's "five" was the seat's miscount. The PASS stands.
Surface 8 files, +740 / −10. check-governed-merges.mjs --pr 25130 is read again at landing.
Evidence 133 new pins (56-case predicate table, writer, parse and round trip through the sync door with inline and named mappings, and through the async job door), plus the admin door's pin in plugin-auth. Seven ablations each red the pins they own. @objectstack/rest: 313 files / 6318 tests. @objectstack/plugin-auth: 180 files / 3423 tests.
Gates Local: 71/71 derived and the 10 Declared WIDE population families exit 0; pnpm lint exit 0; both changeset gates exit 0.

Accepted deviations: the changeset is patch under ruling A, not the dispatch's minor; data-action-routes.ts gets a comment-only edit; the admin door's pin sits in plugin-auth on its existing engine double; main was merged once, and the harness seeds the default organization.

Carried to the pre-landing push (this PR merges main before landing anyway, because PR #25124 shares rest-server.ts, and that is a new head owing a new record): the changeset's "What to do" line names csvTextCellNeedsMark, which the package does not publish (record ③ 5). It is reworded there to point at the rule in this repository's packages/rest/src/export-format.ts.

Open with the maintainer: report 6322329836's question about the one stored shape that does not round-trip (keep the rule, or extend it to an escape). It does not block, and either answer lands before the hold lifts.

Landing: held until objectstack-ai/objectui#13315's PR lands and objectstack's .objectui-sha bump over it is on main, and then until every check on the landing head reads success.


Generated by Claude Code

@objectstack-fleet objectstack-fleet Bot assigned os-bill and unassigned os-justin Oct 21, 2026
os-bill pushed a commit to objectstack-ai/objectui that referenced this pull request Oct 21, 2026
…and the server's CSV door

Measurement for objectui#13315 item 2, before the wizard sends a CSV file
source to the server's CSV import door. The browser half (decode, parse,
re-spelled CSV, the default path's real request) runs live; the server's
parseCsvToRows is recorded at objectstack main abc25a3536 and at the head of
objectstack-ai/objectstack#25130 (bf8244cb20). Recorded inputs are the ones
whose difference changes an imported value. No behaviour changes.

Claude-Session: https://claude.ai/code/session_01AFrKwndmrCLtF5V9hNtrw3
Co-authored-by: Claude <noreply@anthropic.com>
github-merge-queue Bot pushed a commit to objectstack-ai/objectui that referenced this pull request Oct 22, 2026
… text (#13316)

Fixes #13315
Clause-②: no

**Build PR, on the maintainer's ruling A** (the option labelled 「A 重写成
CSV 发送 (Recommended)」, recorded on objectui#13315 in comment
6328933548). A CSV file source now reaches the server's CSV import door:
the import wizard sends `format: 'csv'` with `sourceRowsToCsv(rows)`,
the cells the preview parsed. The identity import's adapter takes the
same change. Neither parser changes: `parseDelimited` is byte-identical,
and objectstack's `parseCsvToRows` is not touched.

Dispatched by objectstack `domain:cli` seat 2 on objectui#13315's claim
(comment 6327882289), session
`https://claude.ai/code/session_01AFrKwndmrCLtF5V9hNtrw3`. The
disclosure ruling on objectstack-ai/objectstack#9387 applies: this PR
describes the change at the card's level only.

## What changed

- **The request (`assembleImportRequest`, `ImportWizard.tsx`).** A new
`csvFile` option. With it, the hand-mapped path sends `{ format: 'csv',
csv: sourceRowsToCsv(rows) }`, and a named mapping sends CSV text unless
it declares `sourceFormat: 'json'`, which the door refuses CSV for
(`MAPPING_FORMAT_MISMATCH`). A `csv`-declared mapping sends CSV text
from any source, as before (#13156). An .xlsx sheet and a paste keep
JSON rows. The dry-run pre-check, the background job and the sync import
all send the one request `buildImportRequest` assembles.
- **What carries "this is a CSV file" (H1).** `assembleImportRequest`
cannot see the source, and no new prop was needed. The wizard keeps one
boolean state, `csvFile`, set where the grid is loaded: the upload
step's file path and the `initialFile` effect answer
`delimitedFileDelimiter(file.name) !== null`, a paste answers false, and
reset clears it. `delimitedFileDelimiter` (`importParsers.ts`) is the
name test `parseSpreadsheetFile` already picked its text arm with, moved
into a named function that both now read, so "a CSV file" means the same
thing to the parse and to the request: `.csv`, `.tsv` and `.txt`, the
delimited-text inputs the card's item 2 measured as CSV.
`parseSpreadsheetFile` behaves exactly as before.
- **The identity import (`identityImport.ts`, H2).** The adapter batched
the request's `rows`, and a CSV request has none, so it would have
thrown. `batchIdentityImportRequest` now cuts a CSV request at record
boundaries (`readCsvRequestRecords`): each batch of at most 500 rows is
posted as the header record plus its data records, joined by CRLF, which
for the wizard's text is exactly `sourceRowsToCsv` of that batch's rows.
Each batch's rows are read from the same records, so the
one-time-password reveal still names every row by its email or phone.
JSON rows are batched as before.

Why the adapter reads the wizard's text back instead of receiving rows:
the wizard calls one `importRecords(object, request)` for every data
source and cannot tell the identity adapter from the generic one, which
posts the request as it is. Carrying the rows beside the text would
double the generic door's payload, and a new parameter on
`DataSource.importRecords` would widen a published interface. The scan
splits records the way the door's CSV parse does, keeps each record's
bytes as written, and decides no imported value.

## Measured (H2, H3)

- **H2, numbering across batches.** A one-shot script ran the adapter's
batching on a 1203-line file (a quoted comma, a quoted LF, a quoted
CRLF, doubled quotes, an export-marked cell, a blank line, and rows
whose mapped cells are all empty at preview rows 250 and 500), then
objectstack's `parseCsvToRows` on every batch body, at `main`
`abc25a3536` and at objectstack-ai/objectstack#25130's head `bf8244cb20`
(read from git objects, transpiled unchanged). Both refs read the same:
every batch body equals `sourceRowsToCsv` of that batch's rows, the
adapter's rows equal the preview's rows, and 0 server rows differ from
the preview row at the same renumbered index. One preview row is never
read: row 500, the last of batch 1, whose mapped cells are all empty.
That is B1 at a batch end; sent as one request, the file keeps it. So
numbering stays aligned, and in the identity import B1 holds for the
last row of every batch, not only the file's last. The changeset says
so.
- **H3, a marked header.** On the default path the text is headed by
field names, so a header the export marked (`'=Region`, auto-mapped to
`region`) reaches the server as `region`. Pinned.

## Pins

`ImportWizard.csvFileSource-13315.test.tsx` drives the real wizard to
its real request. A `.csv` file on the default path sends `format:
'csv'` with the corrected preview cells, and the dry run sends the same
payload. A `.tsv` or `.txt` file picked in the upload step is a CSV file
too. A marked header still maps. An undeclared named mapping sends CSV
text with `mappingName`. CONTROLS: a `json`-declared mapping, an .xlsx
sheet and a paste send JSON rows.

`identityImport.csvBatches-13315.test.tsx` covers the batching itself
(quoted commas, a quoted line break, doubled quotes, an all-empty row, a
header alone refused before any request, JSON rows batched as JSON). It
also runs the real wizard and the real adapter on a 1001-row `.csv`
file: three CSV batches cut from the wizard's text, results renumbered 1
to 1001, and a one-time password on row 777 named
`user777@example.test`. CONTROL: a paste goes out as JSON rows.

`ImportWizard.csvDoorParity-13315.test.tsx`, the measurement test, now
pins the shipped route: its B1 case drives the real wizard and asserts
the CSV request. Its FILE TEXT cases and its recorded B2 and B3 rows
stay, as the record of why the file's own text was not sent and of the
shapes the route was accepted with.

Ablation, one-shot, with objectstack's `scripts/ablation-replace.mjs` on
the committed head `ba5cc54`; each mutation was proven on disk and
restored to the HEAD blob:
- A, the hand-mapped arm ignores `csvFile`: 6 red (the .csv, .tsv, .txt
and marked-header pins, the parity B1 case, the 1001-row identity case),
16 green.
- B, the identity adapter ignores a CSV request: 2 red (the batching
case and the 1001-row case), 4 green.

## The shapes the changeset states (B1 to B3)

Stated at user level, per the disclosure ruling. A last row whose mapped
cells are all empty is not imported; in the identity import, the same
holds for the last row of each 500-row batch. A cell that begins with an
apostrophe, continues with `+` or `-`, holds only digits, spaces and `(
) + - . ,` and ends in a tab or a line break keeps the apostrophe, as it
did before. A space before a leading apostrophe is trimmed first, so
such a cell now imports without the apostrophe.

## Acceptance notes

Observations, not filed and not fixed here:
- `parseDelimited`'s doc comment says trailing whitespace is trimmed,
but the code trims both ends of every cell, and B3 rests on the leading
half. Not fixed, because the ruling leaves both parsers unchanged.
Carrier: none.
- Once objectstack-ai/objectstack#25130 lands, the preview still checks
cells as the file spells them (`checkImportCell`), and matches a named
mapping's columns by the header as written
(`savedMappingToDisplayIndexMap`). So a marked select value or a marked
header shows as a preview finding or an unmatched column, while the
server accepts it. Dormant until that PR lands. Carrier: none.
- objectstack's `parseCsvToRows`, read as a function only: a leading
blank line becomes the header row, an inner blank line becomes a row,
and a repeated header keeps the last column, while its doc comment says
the first non-empty line is the header. Not reached from here: this PR
never sends the file's own text. Carrier: none.

## Gates (local; final head `ec515a4` unless a line says otherwise; each
exit code captured before any pipe)

`dispatch-gates.mjs` refuses a non-objectstack tree, so the list is
hand-derived from this repository's `package.json` and AGENTS.md section
9:
- `pnpm --workspace-concurrency=2 --filter "@object-ui/app-shell^..."
build` (29 packages, `plugin-grid` included), at `ba5cc54`: VERDICT
command-exit 0.
- `pnpm --filter @object-ui/plugin-grid type-check`, at `ba5cc54` (no
`plugin-grid` file changed after it): exit 0. `tsc -p tsconfig.test.json
--listFiles` names both 13315 test files.
- `pnpm --filter @object-ui/app-shell type-check`: exit 2 at `ba5cc54`
(TS2322, the new test's endpoint parameter type), fixed in `ec515a4`,
then exit 0. `--listFiles` names the new test once.
- `pnpm --filter @object-ui/plugin-grid lint` and `pnpm --filter
@object-ui/app-shell lint`, at `ba5cc54`: exit 0, 0 errors. eslint on
the changed files at `ec515a4`: exit 0, warning counts unchanged from
the merge base (`ImportWizard.tsx` 9 and 9, `identityImport.ts` 0 and
0).
- `pnpm exec vitest run packages/plugin-grid/`: Test Files 240 passed
(240), Tests 2168 passed (2168).
- app-shell, the 10 test files that read the import surfaces
(`identityImport*`, `importTargetFields`, `ObjectView.importPredicates`,
`ObjectView.headerFold-12705`, the two `ExcelImportBar` files): Test
Files 10 passed (10), Tests 78 passed (78). The full app-shell suite is
left to CI's Test shards.
- `check-changeset-presence`, `check-changeset-no-major`,
`check:new-line-citations`, `check:control-bytes`,
`check:test-path-roots`, `check:vi-mock-specifiers`,
`check:unreferenced-sources`, `check:changeset-claims`,
`check:pending-changeset-literals`: each exit 0.
- NOT MEASURED locally: `check:readme-exports` exited 1 with "the
population COLLAPSED", because 28 packages are unbuilt in this worktree.
CI's Repo Gates runs it.

CI at `ec515a4`: 33 check-runs success (Lint, Type Check, Test and all
16 shards, Test (dist pins), Build & E2E, Bundle Analysis, Repo Gates,
the changeset checks, Governed Surface Queue Guard, Line Citation Gate,
and the rest), 3 skipped (the coverage matrix, dependabot).

Landing order, unchanged from the card: this PR, then objectstack's
`.objectui-sha` bump, then objectstack-ai/objectstack#25130.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01AFrKwndmrCLtF5V9hNtrw3)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
…port-value-context

Claude-Session: https://claude.ai/code/session_01AFrKwndmrCLtF5V9hNtrw3
Co-authored-by: Claude <noreply@anthropic.com>
…ce file, not an unpublished name (#25078)

Claude-Session: https://claude.ai/code/session_01AFrKwndmrCLtF5V9hNtrw3
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2e4476001a37b95f99294d9ddcf1a3dac5742917
Local-runs: none
Scope: packages/rest/src/rest-server.ts, packages/rest/src/export-format.ts, packages/rest/src/import-prepare.ts, packages/rest/src/data-action-routes.ts, packages/rest/src/csv-text-cell.test.ts, packages/rest/src/csv-text-cell-round-trip.test.ts, packages/plugins/plugin-auth/src/admin-import-users.test.ts + packages/rest/src/index.ts, packages/rest/package.json, packages/rest/src/import-template.ts, packages/spec/src/data/field-value.zod.ts, packages/spec/src/data/index.ts, packages/spec/package.json, packages/core/src/utils/import-coerce.ts, packages/core/src/utils/import-mapping.ts, packages/plugins/plugin-auth/src/admin-import-users.ts, packages/plugins/plugin-auth/src/auth-plugin.ts, .objectui-sha, AGENTS.md (Post-Task Checklist step 3 as it stands on main ab488d9b7), and in objectstack-ai/objectui at the pin 56cf4d8e89: packages/plugin-grid/src/ImportWizard.tsx, packages/plugin-grid/src/importParsers.ts, packages/plugin-grid/src/savedMapping.ts, packages/app-shell/src/views/identityImport.ts, packages/plugin-list/src/ListView.tsx; the prose face .changeset/25078-csv-text-cell.md is judged in ②.

This record re-judges the whole net diff on this head. The earlier record 6322695804 (PASS) governs bf8244cb20 only. Inputs: card #25078's body and all fifteen comments (the maintainer's ruling record 6328219098 included), the PR body, file list and three comments (the earlier record and the ACCEPT 6322716136), the net diff of 2e4476001 against its merge base with main (06900c9c2), and the head's check-runs. Disclosure: the ruling on #9387 (5323310017) applies; the cell rule, the mark and the trigger set are named only as the PR body names them.

① Derived judgments

Read on the net diff against 06900c9c2: 8 files, +740/−10, the same eight paths as before. 06900c9c2 is an ancestor of the head, so the merge base with main is the base itself and the two-dot and three-dot diffs agree.

  1. What moved since the earlier PASS — exactly what the PR body says, and nothing else. Two commits: 42fa4f12b, a merge of origin/main 06900c9c2 with no conflict, and 2e4476001, one changeset line. The old net diff (fccbd9d0d5..bf8244cb20) and the new net diff (06900c9c2..2e4476001) were compared hunk body by hunk body: every added and removed line is identical except the changeset's "What to do" sentence, which now points at the rule in this repository's packages/rest/src/export-format.ts instead of naming csvTextCellNeedsMark; the hunk positions are identical in all eight files. So the code, the rule and the pins are byte-for-byte the ones the earlier record read and the maintainer's ruling A (6328219098) keeps.

  2. The merged rest-server.ts still routes every CSV export through the cell rule; PR feat(rest)!: retire GET {base}/docs, the OpenAPI document's HTML viewer page; /openapi.json stays #25124 bypasses nothing. Of the PR's eight paths and the cited files, the only one main moved between the old merge base fccbd9d0d5 and 06900c9c2 is rest-server.ts, by 1b3b65e64 (PR feat(rest)!: retire GET {base}/docs, the OpenAPI document's HTML viewer page; /openapi.json stays #25124), and that commit's diff on the file is two comment hunks (the import comment near line 393 and the OpenAPI docblock near line 4935); it adds or removes no code. At the head, formatCsvCell (rest-server.ts:1130) marks a string only when the column is a text column (:1133, through markCsvTextCell), before the RFC-4180 quoting, and rowsToCsv (:1169) derives textColumns once per export from isCsvTextColumn(metaMap.get(f)) (:1178), marks every header label with the flag fixed true (:1179) and every data cell by its column's flag (:1183). rowsToCsv has exactly two call sites in the repository's non-test source, both in the export route of data-action-routes.ts: the data chunks (:1320) and the zero-row header (:1376). No other CSV writer exists in any src tree at the head (a repository-wide read for text/csv, rowsToCsv, toCsv( and csvToRows outside packages/rest returns nothing). The route's third arm, the import template (?template=, :992), answers xlsx only (answerImportTemplate, rest-server.ts:5586, writes a workbook buffer and sets an xlsx content type), so it is not a CSV outlet. Right.

  3. Served answer GET /api/v1/data/:object/export?format=csv — as the PR body states it, on both paths. format is read at :995-997 (anything but json or xlsx is csv). The metadata path builds metaMap from the schema (:1150) and the raw path is the catch that leaves it empty (:1167), so on the raw path isCsvTextColumn(undefined) is true for every column and every string cell goes through the rule; on the metadata path a column declared in the spec's numeric, boolean, date or datetime classes is not a text column (export-format.ts:291, over CSV_NON_TEXT_TYPES composed from NUMERIC_VALUE_TYPES, BOOLEAN_VALUE_TYPES, CALENDAR_DATE_TYPES and INSTANT_TYPES, which are defined at packages/spec/src/data/field-value.zod.ts:64, :69, :74, :79, re-exported by packages/spec/src/data/index.ts:201 and reachable through the package's ./data subpath). packages/spec/** is untouched. A reference display title or an option label reaches formatCsvCell as a string in a lookup or select column, which is a text column. Right.

  4. xlsx and json export — unchanged by construction. The xlsx arm writes headerLabel and formatRowCells straight into the worksheet (:1324, :1328, :1378) and the json arm writes formatRowForJson (:1349); none of those three functions is touched by the diff and none reads the rule. Right.

  5. The cell rule, defined once. csvTextCellNeedsMark (export-format.ts:249) is the only predicate: the text's first character is in the trigger set AND the text holds at least one character outside the numeric-text set (digits, the space and the characters a signed amount or a phone number is written with). markCsvTextCell (:254) prefixes one mark; unmarkCsvTextCell (:268) removes exactly one mark when and only when the remainder satisfies the predicate. The only readers in non-test source are the writer (rest-server.ts:1133) and the parse (import-prepare.ts:80, :90). Right. The 56-row predicate table, the declared-type table, the writer tables on both paths and the parse table are byte-identical to the ones the earlier record counted; the pins stand and CI's Test Core concluded success on this head.

  6. Every CSV import door goes through the reversal, before any mapping. parseCsvToRows (import-prepare.ts:46) reads each header cell through unmarkCsvTextCell before .trim() and before the inline mapping[h] lookup (:80-81), and each data cell through it (:90). prepareImportRequest (:378) is the only caller of the parse (:481, the csv branch); the json branches (:478, :491) apply only the inline rename and the xlsx branch (:482-490) calls parseXlsxToRows, so the json and xlsx payloads read no mark. A named mapping is applied by core's applyMappingToRows after the parse (:553), which reads row[source] by the already-unmarked header. The three doors all parse through prepareImportRequest: the sync import (data-action-routes.ts:369), the async import job (:483) and plugin-auth's runAdminImportUsers (admin-import-users.ts:550, forwarding the csv body), mounted at ${basePath}/admin/import-users (auth-plugin.ts:3442). The repository holds no other CSV parser in any src tree. The third call site of applyMappingToRows (service-automation's connector-pull.ts) takes records, not CSV text, and is outside these doors. Right.

  7. The objectui half on the pin meets this PR's import door as the PR body claims. .objectui-sha is not in the file list and its blob at the head equals main's (56cf4d8e89), which is objectui's origin/main and the merge of fix(plugin-grid,app-shell): a CSV file reaches the import door as CSV text objectui#13316. Read at that pin with git show:

    • assembleImportRequest (ImportWizard.tsx:985) sends { format: 'csv', csv: sourceRowsToCsv(rows) } for a CSV file on the hand-mapped path and for a named mapping unless it declares sourceFormat: 'json' (:1019-1024); buildImportRequest (:2731) is the one assembly the dry run, the sync import and the background job all send. The payload carries mappingName or the write-mode keys; it never carries an inline mapping, so the earlier record's out-of-scope note about inline mappings keyed by header is moot on this pin.
    • Cells travel verbatim. The hand-mapped rows (buildRawRows) and the named-mapping rows (buildSourceRows, savedMapping.ts:44, out[h] = v) copy the parsed cell strings as they are, keyed by field name or by the file's header as parsed; sourceRowsToCsv quotes under RFC 4180 and marks nothing. The browser parse (parseDelimited, importParsers.ts:31) trims a cell's ends only (:44, :46, :52); a mark is a cell's first character and not whitespace, so a marked cell and a marked header reach the server marked, and the server's parse removes the one mark before core's named-mapping rename reads the header. A stored value beginning with a space is not marked by the rule and is trimmed by the wizard as it was before this change, which is also what core's default trimWhitespace does (import-coerce.ts:653, import-mapping.ts:192).
    • The identity import adapter (identityImport.ts, readCsvRequestRecords and batchIdentityImportRequest) cuts a CSV request at record boundaries the way this parse does and re-joins the header record with each batch's records by CRLF, keeping every record's bytes as written, so the admin door receives the marks too. objectui#13316's own stated consequence (a batch's wholly-empty last record is dropped) is that PR's, not this one's.
    • The console export still streams through this repository's route (dataSource.exportDownload, ListView.tsx:4985-5021) when streaming is on; the client-side fallbacks are the family finding the round-23 report filed out of scope, unchanged by either PR.
      So after a console export and a console re-import of a CSV file, every marked cell is reversed at this repository's door. Right.
  8. Public surface of @objectstack/rest — nothing added, one published behaviour changed and declared. packages/rest/src/index.ts publishes prepareImportRequest and parseCsvToRows (:30-31) and, from export-format.ts, only buildFieldMetaMap and the ExportFieldMeta type (:48-49); the five new exports of export-format.ts and rowsToCsv are not re-exported, formatCsvCell is module-private, and package.json serves only . from dist/ with files: dist. parseCsvToRows keeps its signature; what it returns for one cell shape changes, and the PR body declares it. The CSV doors' accept set is unchanged: no request they accepted is refused and no new key is read. Right.

  9. data-action-routes.ts — comment only, and true. The nine changed lines are the route docblock (:888-897), correcting "the raw stored values stream through unchanged" for csv. One sibling comment inside the handler (:1104-1105, "falls back to raw values, byte-identical to the un-formatted path") speaks of the value formatters, not the CSV bytes; it is not made false, only loose. Non-blocking, noted in ③.

  10. Control bytes — none. A read of all eight blobs at the head for any C0 byte other than LF, and for DEL, returns 0 hits in each; the whitespace members of the trigger set are spelled as escapes and the mark by code point.

  11. No governed path, no size question. The file list touches none of Prime Directive feat: Comprehensive CRM example demonstrating all ObjectStack protocol features #14's surfaces (.changeset/ is not one), Governed Surface Queue Guard concluded success, and the change is 750 lines, under the 3,000-line human-merge threshold.

  12. Check-runs on 2e4476001 (read twice, 2026-10-22, latest run per name, 41 names): 32 success, 9 skipped, none in progress or failing at the second read. The five required contexts all concluded success: Lint & Repo Gates, TypeScript Type Check, Test Core (and shards 1/6 to 6/6), Build Core, Governed Surface Queue Guard. Also success: Check Changeset (a second run, started 05:31Z after the PR body edit, had been in progress at the first read and concluded success by the second), Lint · ESLint, the four Type Check lanes, the Lint lanes, Dogfood Regression Gate, Flag docs affected by code changes, the claim and single-writer gates, and Check Documentation Links. Skipped (path-scheduled or opt-in; not a verdict either way): Console Pin Gate, Temporal Conformance, the dogfood matrix, Dogfood Verify CLI, Build Docs, the two opt-in lanes; Auto Label and Check PR Size skipped on the edited event after succeeding on the push. origin/main (ab488d9b7) is five commits past the base and none of them touches any of the PR's paths or the cited files, so the queue's rebuild sees the same overlap.

② Semver level

  • @objectstack/rest: patch — matches what the diff publishes. AGENTS.md Post-Task Checklist step 3 as it stands on main (ab488d9b7, unchanged since f2b6cf230b): the level follows the Metadata-protocol kind alone, none takes patch, and "a non-protocol change, widening or breaking, ships patch and keeps its BREAKING banner and ADR-0087 disposition". The changeset carries the BREAKING banner, exactly one ADR-0087 marker (not-required (no-migration-prescription), in the body's comment form, with reasoning that holds on the diff: no spec key, authorable spelling, object definition or stored shape moves, no exported declaration is removed or narrowed, the package publishes), and both line-initial declarations. The package is released (18.0.0, files: dist), so a changeset is owed. Check Changeset concluded success on this head with the current body.
  • Clause-②: no (narrowing) — right, line-initial on the PR body and in the changeset. no: no new key on any published payload and no new published name (①.8). (narrowing): the CSV export serves different bytes for one class of text cells, and the CSV doors store a user-authored cell of one shape without its leading mark where they stored it verbatim before — BREAKING for a consumer reading the file other than through these doors, and exactly the arm step 3's closed pair admits. Step 3's migration clause is not owed: nothing an author can write is removed or renamed.
  • Metadata-protocol: none — right. No spec key, accepted value, metadata type, meaning or default moves; packages/spec/** is untouched; the declared-type decision only reads the spec's existing classes.
  • The changeset, sentence by sentence against the head. The title line carries fix(rest)!:. "The rule below is defined once, in csvTextCellNeedsMark": true at the head (export-format.ts:249; ①.5). "The export" bullet: the marking position before quoting, the text-cell definition by declared type with header labels and reference titles, the raw path's every-string-cell rule, and the never-marked numeric-text cells all match the code (①.3, ①.5). "The import" bullet: parseCsvToRows, the header row, the one-mark-only rule, the three doors by their paths, inline or named mapping — all match (①.6), and the admin door's path is the one plugin-auth mounts. The one-exception bullet (a stored value whose first character is the mark followed by a remainder the rule would mark does not round-trip) follows from markCsvTextCell leaving a value whose first character is not in the trigger set and unmarkCsvTextCell then removing the mark; the maintainer kept it (6328219098). The "Measured" bullet (the leading-whitespace members of the trigger set are marked, reversed, and then trimmed by core's default trimWhitespace as before; exact under trimWhitespace: false) is pinned in csv-text-cell-round-trip.test.ts and the trim gate is core's (import-coerce.ts:653). The "Unchanged" bullet matches ①.4 and ①.6. The "What to do" sentence now names the repository path and no unpublished function; the earlier record's ③5 is discharged. Every sentence is true at this head.

③ Boundary flags

  1. The earlier open question is ruled. Report 6322329836's question (keep the rule, or extend it to an escape over leading marks) was put to the maintainer and answered A, keep the rule exactly as chosen (ruling record 6328219098). The code at this head is the code that ruling names (①.1). Discharged.
  2. The earlier record's ③2 (the numeric-text set against the private order) was answered by the seat in the ACCEPT 6322716136 as an exact match. This record does not read the dispatch order; the set's definition is byte-identical at this head (①.1), so that answer carries unchanged.
  3. The ACCEPT's carried item (the changeset's "What to do" rewording on the pre-landing push) is done (①.1, ②). Discharged.
  4. Dev deviations in the latest os-dev-report (6333504652), each answered:
    • pr_body_replacement went beyond the two named edits (tests and gates readings moved to the new head, ruling A recorded, the merge and session added, the objectui note moved to the new pin): the body as read for this record carries them, and every factual claim in it that this record can check holds (①.1, ①.2, ①.7, ①.12); the ruling-A sentence and the pin note are right. Accepted.
    • plugin-auth typecheck first exit 2 from a missing build, then 0: local only; CI's TypeScript Type Check is the reading. Accepted.
    • first gate pass 69 of 71 with two PREREQUISITE NOT MET, then a full re-run 71 of 71 as a fresh record: local only; Lint & Repo Gates is the reading. Accepted.
    • ENOSPC from another agent's scratch directory, two runs discarded as NOT MEASURED and re-run, nothing of theirs touched: no effect on the diff. Accepted.
    • the dogfood closure built with CI's turbo spelling: local only. Accepted.
    • the changeset's intro sentence still names csvTextCellNeedsMark as where the rule is defined: true at the head (②); the earlier ③5 concerned the "What to do" pointer only. Accepted.
    • commit trailers in the model-free pair: AGENTS.md's form; not part of the diff. Accepted.
    • origin/main moved past 06900c9c2 and was not merged again: verified, none of the five newer commits touches the PR's paths or the cited files (①.12), and the queue rebuilds on current main. Accepted.
    • the worktree removed after the report: process only. Accepted.
      open_questions is empty.
  5. Non-blocking, for the seat: the in-handler comment at data-action-routes.ts:1104-1105 still reads "byte-identical to the un-formatted path"; it describes the value formatters and is not false for csv as written, but a reader could take it as the file's bytes. A prose-only edit on a later head, or leave as is; not a verdict item.
  6. Non-blocking, carrier objectui: parseDelimited at the pin trims each cell's ends, so a stored text value with leading or trailing whitespace is trimmed in the browser before the CSV text is sent (pre-existing wizard behaviour; the server's default trim does the same, and trimWhitespace: false cannot restore what the browser removed). Not this card's finding and not a change made by either PR; recorded so the family's closing card can decide whether it is one.
  7. Landing: both prerequisites of the maintainer's choice B are on main (objectui 56cf4d8e89; .objectui-sha bump 06900c9c2), the body no longer carries Blocked-by, the PR is still a draft, and every gate-carrying check on this head concluded success. Nothing in this record holds the landing.

Implemented-by: claude/issue-25078-export-value-context
Reviewed-by: session_01AFrKwndmrCLtF5V9hNtrw3

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT

domain:cli seat 2 (#22648) · os-bill · session session_01AFrKwndmrCLtF5V9hNtrw3 · 2026-10-22T05:45Z · head 2e4476001a37b95f99294d9ddcf1a3dac5742917 · takeover claim on #25078 → pre-landing report 6333504652 → review of record 6333696207 (CONTRACT_REVIEW_TIER, PASS on this head; it supersedes the PASS 6322695804 on bf8244cb20).

Check Reading
Shape Draft, base main. First line Fixes #25078; Clause-②: no (narrowing) and Metadata-protocol: none line-initial; the Blocked-by: line is gone (both prerequisites are on main). One changeset, @objectstack/rest: patch, BREAKING banner, one ADR-0087 marker. 8 files, +740 / −10. check-governed-merges.mjs --pr 25130: not governed, 750 lines ≤ 3000. Body applied by the seat from the report's replacement text (9999 bytes, read back identical).
What moved since the earlier ACCEPT 6322716136 A conflict-free merge of origin/main at 06900c9c2 (the .objectui-sha bump past objectstack-ai/objectui#13316, and PR #25124's two comment hunks in rest-server.ts), and the changeset's "What to do" sentence, which now points at the rule in packages/rest/src/export-format.ts. The record confirms the code, the rule and the pins are byte-identical to bf8244cb20. Seat spot-check of the record: rowsToCsv is defined at rest-server.ts:1169 and called only from the export route (data-action-routes.ts:1320, :1376) at this head.
Landing prerequisites objectstack-ai/objectui#13316 merged as 56cf4d8e89; this repo's pin bump PR #25160 merged as 06900c9c2; .objectui-sha on main is 56cf4d8e89. The maintainer's ruling on the open question is A (6328219098): the rule ships as chosen.
Gates Check-runs on this head: 39 success, 9 skipped by design, 0 failure, 0 in progress; the five required contexts success. Local: 71/71 derived families exit 0 in one run, --ran reconciled; @objectstack/rest 314 files and @objectstack/plugin-auth 183 files green; the dogfood battery 287/287 files on this head.
Changeset prose Every sentence true at this head (the record verifies each); the earlier ③ 5 is discharged.
Out of scope Noted, not filed: a loose in-handler comment at data-action-routes.ts:1104 ("byte-identical to the un-formatted path"; describes value formatters); the import wizard trims each cell's ends in the browser before sending (pre-existing, matches the default trim).
Landing Not governed ⇒ pr_ready + automerge_enable through the relay in this stroke.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 22, 2026 05:46
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 22, 2026 05:46
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 22, 2026
Merged via the queue into main with commit c4b49c3 Oct 22, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-25078-export-value-context branch October 22, 2026 06:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

3 participants