Skip to content

fix(rest): the import template answers to the import door's gates, not the export's (#20896) - #20977

Merged
os-justin merged 5 commits into
mainfrom
claude/issue-20896-template-import-door
Oct 1, 2026
Merged

os-justin merged 5 commits into
mainfrom
claude/issue-20896-template-import-door

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Closes #20896
Clause-②: no (no schema or published-export change; the route and its closed parameter set are unchanged, as the ruling states)

Implements ruling A on the card (comment 5921162178, its execution parameters taken whole, per the claim 5921450654): GET /api/v1/data/:object/export?template=true is judged by the import door's gates, not the export door's. Branch base: f6ccca4a44.

What changed

  • packages/rest/src/rest-server.ts
    • The export route picks its gates by what the caller asked for. A request whose template value reads true goes through the new enforceImportTemplateGates. The value is read by the template reader itself (readTemplateMode, given the template key alone), so no second spelling of that reading exists. Every other request goes through enforceApiAccess(..., 'export') and enforceExportPermission exactly as before. The gates still run before the query-string gates, in the same position, so template=true&limit=5 from an importer is still refused 400 with the reason, not 403.
    • enforceImportTemplateGates:
      1. The object half is the import door's own first gate, the same call POST /data/:object/import makes before it parses: enforceApiAccess(..., 'import') (404 not exposed, 405 neither create nor update exposed).
      2. The caller half is the create permission: the security service's explain({ object, operation: 'create' }). allowed: false, or a throw, answers 403 PERMISSION_DENIED. No security service, or one without explain, allows, the stance enforceExportPermission takes.
    • Docblocks: the template paragraph of DATA_EXPORT_PARAMS and answerImportTemplate now name the import door's gates.
  • packages/rest/src/import-template-route.test.ts: the pins below. The three field-level-security fixtures now grant create through explain instead of canExport, which no longer reaches the template.
  • content/docs/permissions/permission-sets.mdx: the allowExport section says the template is gated by create, never allowExport.
  • .changeset/20896-template-import-door.md: patch, new. The still-pending .changeset/18386-export-import-template.md is NOT edited: the maintainer answered B on the decision 5922804275 (「20991 20977 都不改」, 2026-10-01), and the one-sentence correction proposed at 501dca7347 was reverted at 8e2d1fda64 (the file is byte-identical to the merge base).

Premise measured: there is no route-level caller create check to reuse

The dispatch asked to reuse the create check the import door enforces. Read at f6ccca4a44:

  • The import door's route-level gates are enforceApiAccess(..., 'import') (stage 1) and enforceApiAccess(..., 'import', { writeMode }) (stage 2). Both are OBJECT-level (enable.apiMethods, 404 / 405).
  • The caller's create permission is never asked before a write. The engine's security middleware judges it on each written row, and runImport records the refusal as a failed row (PERMISSION_DENIED) inside a 200 report (the per-row catch in import-runner.ts).

So the template branch reuses stage 1 verbatim and asks the security service for the middleware's create verdict through ISecurityService.explain. That member is not optional. The contract names it as the composition for an object-level verdict with no dedicated method, and mayReadRunState in packages/runtime/src/domains/automation.ts is the in-repo precedent. Nothing here is re-derived from permission sets. Stage 2 is not asked: it needs the write mode a request body names, and a template request names none.

Fork clause: not triggered

The template's columns do not depend on a write mode. templateColumns(schema, { explicitFields, permitted }) takes none. permitted comes from ISecurityService.getWritableFields(object, context), whose signature has no operation (plugin-security's implementation reads the field mask only). The import door's writeMode check is a gate (404 / 405) and hands nothing to the column computation. No mode was picked.

Pins

In import-template-route.test.ts:

  • create on the object, no allowExport: 200 and the workbook; canExport never asked.
  • no create: 403 PERMISSION_DENIED, empty body, answerImportTemplate never called, getWritableFields never asked.
  • allowExport and no create: 403 PERMISSION_DENIED on template=true, builder never called, canExport never asked.
  • a create verdict that throws: 403, never a grant.
  • object half: an object exposing create without list serves the template; an object exposing get and list only answers 405 OBJECT_API_METHOD_NOT_ALLOWED, builder never called.
  • preservation, non-template export: without allowExport it is 403 EXPORT_NOT_PERMITTED, no row read, create verdict never asked. With allowExport and no create it is 200 with the pre-change headers, text and sha256, create verdict never asked. The existing byte-identity block is unchanged and green.

Each pin is ablated after this PR opens: a mutation proven on disk, the run red, the restore proven, the run green. The readings go in the os-dev-report comment on the card, not here.

Acceptance notes

  • One direction is stricter than a write. explain denies a caller whose permission sets resolve empty, where the middleware skips its CRUD gate. That is reachable only on a deployment with no baseline permission set, and it is the closed direction. mayReadRunState records the same boundary.
  • A caller who holds update but not create is refused the template, even though they could import in update mode. Ruling A names create.
  • The refusal code is PERMISSION_DENIED, a standard code, so the error-code ledger does not change. It is the code the import door's row report carries for the same caller.
  • The objectui#9600 pointer (the button needs create, not export) is the seat's act when this lands, per the claim.

Verification at the current head 8e2d1fda64

Verification at the prior head 501dca7347

  • The 403 answers { success: false, error: { code: PERMISSION_DENIED, message, details: { object } } } through sendError (check:route-envelope refused the flat form).
  • rest typecheck exit 0; rest suite 248 files / 4962 passed, exit 0.
  • Ablations A1–A3 and A5 went red as expected and were restored (blob equal to HEAD).
  • dispatch-gates: 93 accounted, 92 run, 1 NOT MEASURED (check:dual-build-cjs-loads, which needs a full build; CI builds it). check-empty-changeset was red by design at this head (the then-proposed correction of the pending feat(rest): 导出接口新增 ?template=true —— 输出只含「可填列」的 xlsx 导入模板 #18386 note, since reverted).

Verification at opening

  • pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2 src/import-template-route.test.ts: 37 passed, 0 failed, at 617255a015. That is the code commit; the head 3c7d7dd4ef adds only the changesets and the doc.
  • The package-wide test and typecheck, the ablations and dispatch-gates finish after opening. Their exit codes are in the report on the card.

Generated by Claude Code

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 30, 2026
@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest, touching 5 documentable anchor(s).

27 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 9b0de7de73699771b69649bf7b507fbd2a842260.

⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

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 — 15 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 9b0de7de73699771b69649bf7b507fbd2a842260 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from ddd8f08773fcc55423c4556f0780614079dbf3ab — the merge of head 8e2d1fda64a01d9023177e0df3dc00397f848c57 into base 9b0de7de73699771b69649bf7b507fbd2a842260, 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 ddd8f08773fcc55423c4556f0780614079dbf3ab && git checkout ddd8f08773fcc55423c4556f0780614079dbf3ab
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9b0de7de73699771b69649bf7b507fbd2a842260 8e2d1fda64a01d9023177e0df3dc00397f848c57 && git checkout -B drift-repro 9b0de7de73699771b69649bf7b507fbd2a842260 && git merge --no-ff 8e2d1fda64a01d9023177e0df3dc00397f848c57

node scripts/docs-audit/affected-docs.mjs --json 9b0de7de73699771b69649bf7b507fbd2a842260

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 9b0de7de73699771b69649bf7b507fbd2a842260 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

claude added 2 commits October 1, 2026 00:16
…envelope (#20896)

check:route-envelope ratchets the flat sibling-code dialect down and refused
the new 403 body; it is now built through sendError from @objectstack/types.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 501dca7347ab8234eb36d08c984efb7b8ec67a03
Local-runs: none

Read: card #20896 (body; analysis 5915133835; ruling A 5921162178, its Execution parameters binding; claim 5921450654; dev report 5922602955; seat ruling 5922621307); #18386 (body line 94 as corrected; verifications 5914688255 / 5917156039); scripts/check-empty-changeset.mjs; PR #20977 body and file list; the net diff from the merge base 525b8139a2 to the head (5 files, +241 / −32, equal to the PR's file list); the check-runs on the head, read last. Tree line numbers are at the head unless a line says otherwise.

① Derived judgments

Accept-set and surface changes the diff implies

  1. GET /data/:object/export with a template value that reads true — RIGHT. The route (packages/rest/src/rest-server.ts:9713-9720) sends such a request through the new enforceImportTemplateGates (:2556-2585) and every other request through the two export gates verbatim. The two gates are exactly ruling A's. Object half: enforceApiAccess(..., 'import'), the same call the import route makes at :9165 / :9272 before it parses a file; the spec derives import as create ∨ update (packages/spec/src/data/api-derivation.ts:142), so an unexposed object answers 404 and one exposing neither answers 405 OBJECT_API_METHOD_NOT_ALLOWED. Caller half: security.explain({ object, operation: 'create' }, context), refused 403 PERMISSION_DENIED unless allowed is true, a throw counted as a denial. Neither enforceApiAccess(..., 'export') nor canExport is reached on it (the canExport spy is asserted not called in the 200 pin and the allowExport-without-create pin).
  2. Which requests meet which door — RIGHT, closed on both sides. The split reads readTemplateMode({ template }) on the template key alone (packages/rest/src/import-template.ts:306-337): true in any letter case → the import gates; absent or false → the export gates; any other value, a repeated template (an array) included → refused → the export gates and then the same 400 as before from the full read or refuseRepeatedQueryParams. The full read can answer template only when the narrowed read did, so no request reaches answerImportTemplate without the import gates and no non-template request meets them. template=true&limit=5 from an importer passes the import gates, clears refuseUnknownQueryParams (limit is in the closed set) and is refused 400 by the full read (:9745-9756) — the PR body's sentence holds.
  3. The caller half is the middleware's own create verdict — RIGHT, with three closed-direction differences, two of them unnamed by the diff. allowed is !capsDeny && crudAllowed && !denyAll && !delegatorMissing (packages/plugins/plugin-security/src/explain-engine.ts:1575, :1810) over the same checkObjectPermission, requiredCapsForOperation and delegator intersection the middleware runs (security-plugin.ts:2340, :2449, :2205); the contract itself names explain as the composition for an object-level verdict that has no dedicated method (packages/spec/src/contracts/security-service.ts:595-600), and mayReadRunState (packages/runtime/src/domains/automation.ts:303-317) is the precedent. Where explain is stricter than a written row, each in the closed direction:
    • (a) named in the diff — permission sets resolving EMPTY: the middleware guards its capability and CRUD gates on a non-empty permissionSets (the length guard at security-plugin.ts:2340 and :2449); explain runs checkObjectPermission over [] and denies. Reach is wider than "a deployment that configures no baseline set at all": member_default is the additive baseline on every authenticated HUMAN request (ADR-0090 D5; security-plugin.ts:696-709), so an empty resolution needs either fallbackPermissionSet: null, OR a principal-less context on a requireAuth: false deployment (no baseline for a user-less context, :2192). The "only" in the docblock and PR body line 46 is over-narrow; the direction claim is true.
    • (b) not named — the RLS arm: explain composes computeRlsFilter(sets, object, 'insert', context) and denies on a deny-all composition or a composition throw (explain-engine.ts:1759-1810), while the middleware's pre-image RLS gate covers update / delete / transfer / restore / purge only (security-plugin.ts:2643) — an insert is never gated on a composed row filter. A principal whose authored insert policy composes to deny-all, or whose composition faults, is refused the template where the object gate would admit the row. Reachable only through such a policy or a fault; closed.
    • (c) moot on this door — mayReadRunState passes a system context first; this gate does not, and explainAccess has no isSystem arm. REST never sets isSystem on an inbound context (rest-server.ts:2207, :2243), so no wire caller meets it.
  4. Fail posture — RIGHT, and it opens nothing. No security service, or one without explain → pass; explain throwing → deny (:2564-2574). That is enforceExportPermission's stance line for line (:2495-2515) and mayReadRunState's. On a deployment carrying plugin-security, explain is not optional (security-service.ts:768; registered at security-plugin.ts:1936), so the pass arm fires only where no permission sets exist and /data writes are equally ungated; a partial service omitting explain is the contract's declared absence state, tolerated the way every gate in this file tolerates it.
  5. Non-template export — byte for byte as before. The else branch is the pre-change two lines unchanged, nothing after the gates moved, and the preservation pins assert the pre-change headers, text and sha256 with explain never called (packages/rest/src/import-template-route.test.ts:701-723).
  6. Refusal envelope — RIGHT. sendError writes { success: false, error: { code, message, details } } (packages/types/src/response-envelope.ts); PERMISSION_DENIED is a standard code (packages/spec/src/api/errors.zod.ts:79), so the ledger does not move. The route now answers two dialects (the export 403 and the 405 stay flat); that is the ratchet's direction and check:route-envelope is green on the head.
  7. Pins — the four of ruling A are present, and the extras: create without allowExport → 200 with explain asked and canExport not; no create → 403 with the builder spied and not called and getWritableFields not asked; allowExport without create → 403, builder not called, canExport not asked; a throwing verdict → 403 (the same return true path, so the builder is unreachable); object half: a create-only object serves, a get-and-list-only object answers 405 with the builder not called; two preservation pins. Ablations are the dev's report, not re-run here: A1 / A2 / A3 / A5 each red on the pins they target and restored; every pin is reddened by at least one — see ③ for the missing A4 number.
  8. Fork clause — not triggered, verified: templateColumns(schema, { explicitFields, permitted }) takes no mode (import-template.ts:153); resolveTemplateProjection calls getWritableFields(objectName, context) with no operation (:188-203).

Text, sentence by sentence (true at head unless marked)

  • DATA_EXPORT_PARAMS docblock (rest-server.ts:644-650): true.
  • enforceImportTemplateGates docblock: "the same call POST …/import makes before it parses a file" true (:9165); "create ∨ update" true; "The import door never asks it before a write" true — neither import route carries a route-level create check (:9165 / :9190, :9272 / :9289), create is judged per written row and recorded by the per-row catch (packages/rest/src/import-runner.ts:974); "the contract's own bottom line" true (security-service.ts:597-600); "as enforceExportPermission's" true; "reachable only on a deployment that configures no baseline set at all" over-narrow (3a) and silent on 3b — closed direction either way.
  • Route comment (:9705-9712) and the answerImportTemplate docblock ("after the route's query-string gates as well", :9745-9756): true.
  • .changeset/20896-template-import-door.md: every bullet true and pinned; "as POST /api/v1/data/:object/import does" true (the same stage-1 call); "grant it create on the object" true, the object's own exposure being stated in the bullet above it.
  • .changeset/18386-export-import-template.md:33-36, the corrected sentence: true at head, and on main only once this PR lands — on main today the OLD sentence is the true one. "The import's permission checks apply" is a reader-level summary: the import route itself runs only the object-level checks and judges create per row, while the template asks create up front; what a caller must hold is the same.
  • content/docs/permissions/permission-sets.mdx:97-100: true; it names the caller half only, which is the allowExport section's scope.
  • PR body: line 9 true (item 2); 11-12 true; 14 true (the three field-level-security fixtures now grant through explain); 16 true; 20-25 true, "That member is not optional" included; 29 true; 35-40 true against the test file except "The existing byte-identity block is unchanged" — its three assertions are unchanged, but bootExportFixture / exportOnce were re-signatured (an optional security service, a findData spy, a { route, findData } return); 46 over-narrow as 3a; 47 true and consequential (a caller holding update but not create may import in update mode and is refused the template — faithful to ruling A, which names create); 48 true; 53 true; 56 true; 60-61 historical and superseded by 51-56.
  • Dev report: "No security service, or one without explain, passes, as enforceExportPermission does" true; the premise paragraph true; its last sentence on the declaration true at origin/main dfe5a0863f (see ②).
  • No other sentence under content/docs, docs, skills, apps, packages/rest/src or packages/spec/src ties the template to the export gate (grep at head: only rest-server.ts:357, which names the door, not the gate).

② Semver level

  • .changeset/20896-template-import-door.md — @objectstack/rest: patch, right. The template mode is unreleased: .changeset/18386-export-import-template.md (minor) is still pending on origin/main at dfe5a0863f; packages/rest/CHANGELOG.md tops at 17.5.0 with no template=true entry (its two "import template" hits, :10384 / :19511, are the older empty-export header note). Both changesets consume in one release, the bump is the 18386 minor, and the released route answers ?template=true with 400 today, so no published accept set widens or narrows; the non-template export is byte-identical.
  • The correction inside the 18386 note is the right place for the sentence: AGENTS.md :700 refuses an erratum in a later entry, and that note is what an upgrading reader greps for the template. The gate's red is the DELIBERATE CORRECTION class — its annotation names .changeset/18386-export-import-template.md with the two-class text of scripts/check-empty-changeset.mjs:541-563 — and its confirmation is a person's, not this review's.
  • The declaration reads no with no arm, in the PR body and in the new changeset, well-formed per scripts/pm/clause2-line.mjs (the parenthetical opens with reasoning, not an arm word). It holds exactly while the 18386 changeset is still pending when this PR lands; were a release cut first, the same diff would narrow a published answer (403 where 200 for allowExport without create) and would need re-declaring as yes (narrowing). So the line is true on main the moment this PR lands, not after another release.

③ Boundary flags

  • Q1 (dev open_questions, the 18386 correction): the correction is judged right — the old sentence is false at head, restoring it would republish a false sentence, and the per-entry rule refuses an erratum elsewhere. Recommendation A is the right one. The confirmation is escalated to the maintainer by seat ruling 5922621307 (a decision comment plus needs-user-decision on the PR). Check Changeset stays red by design on this head; whether it is a required context is the dev's reading and not verifiable from an unauthenticated read.
  • Deviation (no route-level create check to reuse): accepted, verified against the tree (item 3 and the docblock lines). The composition through explain is the contract's sanctioned route and the mayReadRunState precedent; nothing is re-derived from permission sets.
  • out_of_scope_findings 1 (explain stricter on empty sets): answered in 3a — reach is baseline-disabled deployments AND principal-less contexts on auth-optional deployments — and 3b is a second closed-direction arm the note does not name. Follow-up for the dev, not blocking: widen the docblock's "only" sentence and PR body line 46 to name both arms, or measure that computeRlsFilter for insert cannot compose to deny-all in the shipped policy set.
  • out_of_scope_findings 2 (PR body head paragraph): done — the body carries "Verification at the current head 501dca7" (lines 51-56).
  • Ablation numbering: the report names A1, A2, A3 and A5, and nothing says what A4 was or why it is absent. Every pin is reddened by at least one of the four, so coverage holds; the dev should state the gap. Not blocking.
  • Update-only importers (PR body line 47): a caller holding update but not create may import in update mode and is refused the template. Faithful to ruling A's "create"; named for the maintainer's awareness, not as a deviation.
  • check:dual-build-cjs-loads NOT MEASURED locally (it needs a full build): Build Core and Test Core are green on the head.
  • objectui#9600's pointer is the seat's act at landing (claim 5921450654); feat(rest): 导出接口新增 ?template=true —— 输出只含「可填列」的 xlsx 导入模板 #18386 body line 94 is corrected as the ruling asked (strikethrough plus the ruling quote).
  • Serial condition: the base 525b8139a2 already carries readTemplateMode and the template branch, so PR feat(rest): GET /data/:object/export?template=true answers an xlsx import template (#18386) #20683 was on main before this branch forked.

Check-runs on 501dca7347 (46 runs, 35 names after dedupe by newest started_at, read last): 30 success; 4 skipped (Auto Label, Check PR Size, Console Pin Gate, Packed-tarball smoke (opt-in)); 1 failure — Check Changeset, the deliberate-correction red named above. None still running. The commit's combined status carries one pending Vercel status — a preview deploy, not a check-run and not a gate.

Implemented-by: claude/issue-20896-template-import-door
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1

VERDICT: PASS

Adopted and posted by domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1) · 2026-10-01T01:20Z · rendered by the seat's at-tier review subagent on this head. The seat read its served tier family from the subagent transcript before posting.

  • The correction of the pending .changeset/18386-export-import-template.md needs a person's confirmation, as check-empty-changeset requires. The seat puts it to the maintainer in this act: a decision comment on this PR, and needs-user-decision. The PR stays draft until the maintainer answers.
  • Timing: Clause-②: no and the corrected 18386 sentence are both true only while the 18386 note is still pending when this lands. If release PR chore: version packages #20639 merges first, the line becomes yes (narrowing) and the correction becomes an erratum. The decision comment says so.
  • The closed-direction divergences (empty permission-set resolution, including a principal-less context on a requireAuth: false deployment; explain's insert-RLS deny-all arm; no isSystem pass) are recorded as follow-ups, not blockers. So are the over-narrow "only" wording and the "byte-identity block is unchanged" line in the PR body.
  • An update-only importer is refused the template. This is faithful to ruling A's "create", and recorded for awareness.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

os-decision-facets

决策请求:确认更正 #18386 那条尚未发版的发布说明(PR #20977)· 2026-10-01T01:21Z

domain:spec seat 5(session_01Sfe5YjBLwB9J3y8fvm2xq1)。

一句话问题: 按您同意的 #20896 裁定 A,导入模板改为看导入权限和新建权限。但 #18386 那条还没发版的发布说明,写的仍是「沿用导出的两道权限闸」。本 PR 把这句改成了与新行为一致的说法。仓库检查规定,改一条待发的发布说明要由人确认(scripts/check-empty-changeset.mjs:605-612)。

背景与前提(每条可复核):

  • 说明文件 .changeset/18386-export-import-template.md,仍在 main 上待发,导入模板功能本身也还没发版。
  • 原句: 「与导出相同的两道权限检查:对象不开放导出答 405,调用者没有导出权限答 403。」
  • 改为: 「走导入的权限检查,不走导出的:对象既不开放新建也不开放更新答 405,调用者对该对象没有新建权限答 403。不需要导出权限(allowExport)。」
  • 本 PR 的档位审查已通过(记录见本 PR)。CI 只有这项检查按设计标红。

选项 × 代价:

  • A 确认更正: 同一次发版里,这条说明与实际行为一致。代价:确认这一步。
  • B 不更正: 撤回这句改动,只靠本 PR 自己的补丁说明描述新闸门。代价:同一次发版会同时出现两条互相矛盾的说明。

业务含义: A 等于客户看到的模板权限说明就是实际规则;B 等于同一个版本的发布说明自相矛盾。

时间要求: 只有在发版 PR #20639 合并之前确认并合入,这条说明才仍是"待发"。如果发版先发生,这句改动就变成勘误,本 PR 的 Clause-② 也要改为收紧。

四棱:

  • ① 长远:一条能力的权限规则,在发布说明里只能有一种说法。
  • ② 拉动:今天就有人撞上。没有导出权限、但能新建的用户需要下载模板;读到原说明的人会以为要先开导出权限。
  • ③ 防 AI 犯错:说明写错,读说明配置权限的人或 AI 会给错权限(多开导出权限);写对是唯一正确指引。
  • ④ 不扩散:只改一句,不新增声明。

Prior rulings read: DELIBERATE CORRECTION → ruling D on #17712(人工确认路径);#20896 裁定 A 5921162178;thread: none

推荐:A。 只看①选 A;②③④ 是否翻转:否。回退项:B。置信缺口:新行为由测试钉住,未在运行中的部署上实测。

裁后执行: 维护者答 A 后,席位把本 PR 转为 ready 并放入合并队列(Check Changeset 按设计保持红,本评论即其记录);合并后收尾 #20896,并在 objectui#9600 说明「下载模板需要新建权限,不需要导出权限」。答 B 则让 dev 撤回那一句再审。

你要做的: 回一个字母,A 或 B(可与 PR #20991 一起答)。


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

The maintainer's answer: B, do not correct the 18386 note; the sentence change is reverted, and the gate change still lands

domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1) · 2026-10-01.


Generated by Claude Code

…on main (#20896)

The maintainer chose B on decision 5922804275: the pending note is not
corrected. The template's own patch changeset stays.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8e2d1fda64a01d9023177e0df3dc00397f848c57
Local-runs: none

Delta re-review on the head above. Read: card #20896 (body; analysis 5915133835; ruling A 5921162178, its Execution parameters binding; claim 5921450654; dev report 5922602955; seat ruling 5922621307; revert-round report 5923358262); on PR #20977 the prior PASS record and seat adoption 5922797800 (at 501dca7347), the decision 5922804275 and the seat's record of the maintainer's answer B 5923146385; #18386 (body line 94 as corrected under ruling A; verifications 5914688255 / 5917156039); scripts/check-empty-changeset.mjs and scripts/pm/clause2-line.mjs on origin/main; PR #20977 body and file list (4 files, +237 / −29, equal to git diff --stat origin/main...head); the net diff from the merge base 525b8139a2 to the head; git diff 501dca7347 8e2d1fda64; the check-runs on the head, read last. Tree line numbers are at the head.

The delta, measured. The head's parent is 501dca7347 (one appended commit, linear; 5 commits on the branch, none rewritten). git diff 501dca7347 8e2d1fda64 touches exactly one path, .changeset/18386-export-import-template.md (+3 / −4), and nothing else: rest-server.ts, import-template-route.test.ts, permission-sets.mdx and .changeset/20896-template-import-door.md are byte-identical to 501dca7347. The 18386 note's blob at the head is 542d0899944e, equal to the blob at the merge base 525b8139a2 and at origin/main 2f2fa11d75 (git diff --exit-code against both: empty). The net diff against main therefore no longer names that file at all (A the 20896 changeset, M the three others). The edit being reverted was introduced at 3c7d7dd4ef and is gone. main moved on none of this diff's files between the merge base and origin/main (empty stat on the five paths), so the prior record's tree readings hold at this head unchanged.

① Derived judgments

Accept-set and surface changes the diff implies — unchanged from the prior record, re-read on the identical code blob and judged RIGHT again:

  1. GET /data/:object/export with a template value reading true (any letter case, readTemplateMode on the template key alone, packages/rest/src/import-template.ts:306-337) is judged by enforceImportTemplateGates (rest-server.ts:2556-2585): object half enforceApiAccess(..., 'import') — the import route's own stage-1 call at :9165 / :9272, import derived as create ∨ update (404 unexposed, 405 OBJECT_API_METHOD_NOT_ALLOWED for neither); caller half security.explain({ object, operation: 'create' }, context), refused 403 PERMISSION_DENIED through the shared envelope unless allowed is true, a throw counted as a denial; no security service, or one without explain, passes — enforceExportPermission's stance line for line (:2495-2515). Neither export gate nor canExport is reached on it. RIGHT, and exactly ruling A's two gates.
  2. Every other request (template absent, false, or any refused spelling) takes the pre-change two lines verbatim (:9716-9720) and then the same query-string gates and 400s as before; template=true&limit=5 passes the import gates, clears refuseUnknownQueryParams and is refused 400 by the full read (:9745-9756; pinned at import-template-route.test.ts:362 with no security service, which is the pass arm, so the sentence holds for an importer). RIGHT, closed on both sides.
  3. The caller half is the middleware's own create verdict via ISecurityService.explain, a non-optional member (packages/spec/src/contracts/security-service.ts:768); templateColumns(schema, opts) takes no mode (import-template.ts:153) and getWritableFields(object, context) has no operation (security-service.ts:415) — the fork clause is not triggered. RIGHT. The three closed-direction divergences the prior record named (empty permission-set resolution incl. a principal-less context on a requireAuth: false deployment; explain's insert-RLS deny-all arm; no isSystem pass) are unreachable by this delta and stand as recorded there: closed direction, non-blocking follow-ups.
  4. Non-template export: byte for byte as before; the two preservation pins (:701-723) assert pre-change headers, text and sha256 with explain never asked. RIGHT.
  5. Pins: ruling A's four plus the extras (throwing verdict → 403; create-only object serves; get-and-list-only object 405 with the builder spied and not called; builder and getWritableFields never asked on the 403s). Present and unchanged. Ablations are the dev's readings on 501dca7347 (A1–A3, A5), which the identical code blob carries forward; not re-run here.

Text, sentence by sentence, at this head

  • DATA_EXPORT_PARAMS docblock (rest-server.ts:644-650), the enforceImportTemplateGates docblock, the route comment (:9705-9712) and the answerImportTemplate docblock (:10126-10136): true, unchanged; the docblock's "reachable only on a deployment that configures no baseline set at all" stays over-narrow in the closed direction (prior record 3a), unchanged by the delta.
  • .changeset/20896-template-import-door.md (added, @objectstack/rest: patch, Clause-②: no): every bullet true at the head and pinned — create → template with or without allowExport; no create → 403 PERMISSION_DENIED with or without allowExport; neither create nor update exposed → 405 OBJECT_API_METHOD_NOT_ALLOWED "as POST …/import does" (the same stage-1 call); create without list serves; non-template export unchanged. "To let a role download the template, grant it create on the object" true, the object's own exposure stated in the bullet above it. It names neither feat(rest): 导出接口新增 ?template=true —— 输出只含「可填列」的 xlsx 导入模板 #18386 nor the pending note (0 hits), so the revert cut nothing from it. Its sentences are true on main the moment this PR lands.
  • .changeset/18386-export-import-template.md (NOT in the diff; identical to main): its line "The same two permission checks as the export apply: an object that does not expose export answers 405, and a caller without the export permission answers 403" is true on main today and FALSE on main once this PR lands. That is the maintainer's chosen outcome (answer B, 「20991 20977 都不改」, 5923146385), not a finding against this head: the next release compiles that minor entry beside this PR's patch entry, which states the opposite rule for the template. Recorded here so the release reader is not surprised; nothing for the dev to do.
  • content/docs/permissions/permission-sets.mdx:97-100: true at the head — the template is gated by the create permission, never allowExport; it names the caller half only, the section's scope.
  • No other sentence under content/docs, packages/rest/src or packages/rest/CHANGELOG.md ties the template to the export permission (grep at head: rest-server.ts:357 names the door, not the gate; the CHANGELOG's two "import template" hits are the old empty-export header note; content/docs/releases/v15.mdx:171 is sample-row wording).
  • PR body, at this head:
    • "Clause-②: no (…)" — read by clause2-line.mjs as declared no, arm null (the parenthetical opens with reasoning, not an arm word). True, see ②.
    • "Branch base: f6ccca4a44" — true (617255a015's parent).
    • What-changed, rest-server.ts, test file, docs bullets — true, unchanged code.
    • What-changed, changesets: "patch, new" true; "The still-pending .changeset/18386-export-import-template.md is NOT edited: the maintainer answered B on the decision 5922804275 (「20991 20977 都不改」, 2026-10-01), and the one-sentence correction proposed at 501dca7347 was reverted at 8e2d1fda64 (the file is byte-identical to the merge base)" — true at the head: not in the net diff; the correction was carried at 501dca7347 (introduced at 3c7d7dd4ef), reverted at 8e2d1fda64; blob equal to the merge base and to origin/main.
    • "Premise measured", "Fork clause: not triggered", "Pins", "Acceptance notes" — true against the tree, as the prior record found (line 46's "only" over-narrow, closed direction; line 47 faithful to ruling A's "create").
    • "Verification at the current head 8e2d1fda64" (new section): bullet 1 true (measured above); bullets 2–4 are the dev's local readings in report 5923358262 (check-empty-changeset exit 0, check-changeset-no-major and check-adr-0087-registration exit 0, rest typecheck 0, suite 248 files / 4962 passed, dispatch-gates 93 / 92 / 1 NOT MEASURED) — not re-run here; the head's check-runs corroborate every family (Check Changeset, Lint & Repo Gates, the Type Check family, Test Core, Build Core all success). "Dev report 5923358262 on The import template (GET /data/:object/export?template=true) sits behind the EXPORT gate (allowExport): a caller who may import but not export cannot download it — gate it by the import door instead? (from #18386 acceptance-6 verification) #20896" true.
    • "Verification at the current head 501dca7347" — the HEADING is false at this head: 501dca7347 is the prior head, not the current one, and the body now carries two sections so titled. The bullets under it are sha-stamped and true as history, and the last one says the red was "since reverted", so no reader is told the current head is red; still, the seat should retitle it ("at the prior head 501dca7347"). Over-broad label, not a gate; non-blocking.
    • "Verification at opening" — historical, sha-stamped, true.
  • Dev report 5923358262: "one appended commit with no rebase, amend or force-push" true; the blob and merge-base equalities true; "Everything else is unchanged" true (one-path delta); "That changeset never referred to the 18386 correction" true; "main was not merged … the 12 commits since … touch other files of packages/rest (error-response.ts, tests, tsconfig.json), but not this diff's files" true (6 packages/rest paths moved on main since the merge base, none of this diff's).
  • Seat record 5923146385: "check-empty-changeset then has nothing to refuse" true at the head — the gate scans --diff-filter=MD against the merge base and the net diff carries no M/D changeset; Check Changeset is success on the head. "the seat removes needs-user-decision" — the PR carries documentation, size/m, tests, tooling only; done. The PR is still draft, as that record says it stays until this delta passes.

② Semver level

  • .changeset/20896-template-import-door.md — @objectstack/rest: patch: right. The template mode is unreleased: .changeset/18386-export-import-template.md (minor) is still pending on origin/main at 2f2fa11d75, and packages/rest/CHANGELOG.md tops at 17.5.0 with no template=true entry. Both notes consume in one release; the bump is the 18386 minor; the released route answers ?template=true with 400 today, so no published accept set widens or narrows, and the non-template export is byte-identical.
  • The 18386 note is no longer edited, so the DELIBERATE CORRECTION class of scripts/check-empty-changeset.mjs (:541-563, :605-612) does not arise on this head: the gate's M/D scan from the merge base finds nothing, and Check Changeset is green. The cost the script's remedy warns of — "restoring it from the base would put the false sentence back" — is exactly what answer B chose: the pending minor entry will say the export's two checks apply to the template, beside this patch entry saying they do not. A person decided that; it is recorded in ① and is not a defect of this diff.
  • Clause-②: no — the declaration in the PR body (no, arm null, reasoning in the parenthetical) and in the new changeset (no, no arm) are both well-formed per scripts/pm/clause2-line.mjs and both true: no schema change, no published-export change, the route and its closed parameter set unchanged. The same timing condition as before applies: the line holds exactly while the 18386 note is still pending when this lands; were a release cut first, the same diff would narrow a published answer (403 where 200 for allowExport without create) and would need yes (narrowing). True on main the moment this PR lands, not after another release.

③ Boundary flags

  • Revert-round report 5923358262: open_questions empty, out_of_scope_findings empty, premise_still_valid true. No deviation: the instruction (seat record 5923146385) was to restore the one file from the merge base and touch nothing else, and the delta is exactly that.
  • Q1 of report 5922602955 (confirm the correction of the pending 18386 note): answered by the maintainer — B, do not correct. Executed at this head; the gate that asked for a person's confirmation now has nothing to refuse. Closed.
  • Prior report's deviation (no route-level create check to reuse; explain composition): accepted and verified in the prior record; code unchanged; stands.
  • Prior report's out_of_scope_findings 1 (explain stricter on empty sets): the closed-direction arms (3a, 3b, the moot isSystem arm) remain as recorded — follow-up for the dev to widen the docblock's "only" sentence and PR body line 46, non-blocking. Finding 2 (PR body head paragraph): done at 501dca7347; the body now also carries the 8e2d1fda64 section.
  • Ablation numbering (A4 absent from the readings): unchanged, every pin reddened by at least one of A1–A3 / A5; the dev should state the gap. Non-blocking.
  • Update-only importers refused the template: faithful to ruling A's "create"; for awareness.
  • PR body heading "Verification at the current head 501dca7347": the seat's edit, see ①. Non-blocking.
  • check:dual-build-cjs-loads NOT MEASURED locally: Build Core and Test Core are success on the head.
  • objectui#9600's pointer remains the seat's act at landing (claim 5921450654). feat(rest): 导出接口新增 ?template=true —— 输出只含「可填列」的 xlsx 导入模板 #18386 body line 94 stays corrected under ruling A (strikethrough plus the ruling quote); answer B concerned the pending changeset note only.
  • Freshness: the head's check-runs ran on the merge ref against 9b0de7de73; main has since taken three commits (2f2fa11d75 tip) touching no packages/rest source and none of this diff's paths — two unrelated changesets only. The PR reads mergeable: clean.

Check-runs on 8e2d1fda64a01d9023177e0df3dc00397f848c57 (42 runs, 35 names after dedupe by newest started_at, read last): 31 success; 4 skipped (Auto Label, Check PR Size, Console Pin Gate, Packed-tarball smoke (opt-in)); 0 failure; none still running. Check Changeset: two runs on this head, both success, the newest at 02:15:07Z after the seat's body patch. The commit's combined status is success (one Vercel status, "Canceled by Ignored Build Step" — not a gate).

Implemented-by: claude/issue-20896-template-import-door
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1

VERDICT: PASS

Adopted and posted by domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1) · 2026-10-01T02:22Z · rendered by the seat's at-tier review subagent on this head. The seat read its served tier family from the subagent transcript before posting.

  • The flagged heading "Verification at the current head 501dca7347" is retitled to "at the prior head" in this act. The head does not move.
  • The maintainer's chosen outcome (B) is recorded as such: the next release carries the 18386 note's "same two permission checks as the export" sentence beside this PR's patch entry.
  • Not governed. needs:contract-review is already off. The PR goes ready and into the merge queue after this record reads back. At landing, objectui#9600 gets its pointer.

Generated by Claude Code

@os-justin
os-justin marked this pull request as ready for review October 1, 2026 02:23
@os-justin
os-justin added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit 8f78495 Oct 1, 2026
51 checks passed
@os-justin
os-justin deleted the claude/issue-20896-template-import-door branch October 1, 2026 03:59
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/m tests tooling

Projects

None yet

2 participants