Skip to content

Proposal: source-specific Silver model scaffolder contract (backlog 145)

Status: draft proposal for the unimplemented item-145 source-specific Silver-model generator; the generator has not been implemented, tested, or validated against a warehouse. PR #7 separately implements and tests the optional migration source_binding_id evidence field required by this design. No item-145 generator or edition migration models are tracked. The local workspace copier (scripts/scaffold_edition.py) is separate and untracked. Items 144 (schema drift) and 147 (beta gate) are out of scope.

Revision: n09-claude-scaffold-contract-decisions applies the recorded Product Owner decisions below to the original de-claude-scaffolder-design draft. This revision also applies four later findings:

  • The PO decided that the MVP is create-only (D4). This removes the --update mode, the pending journal, orphan tracking, and the per-file rename design. Those were added in response to the n06-gpt1-scaffold-proposal-review findings on update atomicity and orphan ownership. D4 makes those findings moot because no file is ever replaced.
  • The GPT-2 input-contract review found that the scaffolder's inputs need explicit bindings. These bindings are assigned to n09-claude-scaffold-input-contract (§3.3, R1).
  • An independent review found a race between staging verification and publication, and inconsistent collision precedence. Staged files are now verified, sealed read-only, and re-verified before publication, within a stated threat model (§6.2). Symlink and existing-output collisions now have one precedence rule, and --dry-run reports the same collisions (§6.1, §6.2).
  • A filesystem-lifecycle review found that a non-root Linux process cannot rename a sealed 0500 directory into a different parent directory (EACCES, confirmed locally). How to reconcile that with the sealed-until-publication rule is a PO decision (U32), so final generation stays blocked on it. The review also added the following, all of which keep D1–D4 unchanged (§4, §6.2–6.5, §7):
  • a run lock that shows whether a run is live (staging_in_use);
  • identity checks around the rename;
  • runtime handling of publish failures and an errno map;
  • a default-mode result contract;
  • an fd-held path walk;
  • staging recovery states;
  • tighter verification;
  • more fixtures.
ID PO decision Where it applies
D1 Client workspace output. The scaffolder writes only into a separate client workspace, never into the starter repository. §3 --workspace, §4, §5, §7.13
D2 Canonical retail scope. Only the four canonical retail tables and their canonical columns are scaffolded. §3 input rules, §7.8
D3 Fail-closed protection of existing edits. Any edit or state the scaffolder cannot prove it owns stops the run before writes. Interrupted or concurrent runs also refuse. The scaffolder never merges, overwrites, renames, or deletes operator content. §6, §7.2–7.6
D4 Create-only MVP. The MVP has no --update mode. If generated/<source_id> already exists as a regular file or directory, the run always refuses with output_exists; a symlink anywhere on the output or staging path refuses with symlink_refused. Either way, the existing path is left unchanged. A customer who needs a new scaffold creates a fresh client workspace. The full output is staged in an owner-only directory outside dbt model paths, verified, sealed read-only, and verified again. It is then published with one atomic no-replace directory rename on the same filesystem, and the run fails closed if that operation is unavailable. After the complete tree is visible, the scaffolder restores owner write access (0700 directories, 0600 files) so the generated models are ready to edit. §3, §4, §6, §7.2–7.6
ID Review finding Where it applies
R1 Input bindings (GPT-2). n09-claude-scaffold-input-contract defines mapping, source-kind, destination, and selected-table checks. PR #7 adds an optional non-sensitive operator binding field to both dry-run and run-manifest artifacts. Final generation must require the field and verify equality across both artifacts and the invocation. It remains an operator assertion, not source authentication. The core may proceed with plan-only behavior. §3.3, §6.1, §7.7, §8

Earlier PO decisions are retained. source_id must be a generic, non-sensitive alias. User-visible paths are relative or omitted. Generated SQL, YAML, and the manifest are private customer-workspace assets with owner-only permissions.

Notation: - [D#] marks a recorded PO decision, and R1 marks the accepted review finding. - [U#] marks an implementation detail that is still unverified. Each [U#] is assigned to a named follow-up task in Unverified decisions. - [Observed] marks facts read from the repository or the main checkout's working tree. Facts read from the working tree are uncommitted unless stated otherwise.

1. Evidence baseline

Finding Source
[Observed] Mapping contract v1 has only version and tables. Each rule has source, target, and one of six casts (string, integer, numeric, date, timestamp, boolean). Rules carry no keys, source label, or edition. migration/mappings/retail-source.example.json, migration/README.md
[Observed] Canonical retail Bronze/Silver columns: customers(customer_id, created_at, country), products(product_id, product_name, category), orders(order_id, customer_id, order_status, order_date, updated_at), order_items(order_item_id, order_id, product_id, quantity, unit_price). The example mapping covers every canonical column. editions/Snowflake/docs/migration.md
[Observed] The loader validates mapping rules in Python. Those casts do not prove BigQuery or Snowflake castability, precision, or timezone behavior. Mapped targets are added to Bronze, and a source whose dlt-normalized name equals its target is replaced in place. migration/README.md
[Observed] Migration models are opt-in. They are enabled only with --target migration and enable_migration_models: true, and they are selected with tag:medallion. CI runs dbt parse with that target and variable for both editions. editions/Snowflake/docs/migration.md, .github/workflows/dbt-*-ci.yml
[Observed] The editions are separate dbt projects: BigQuery retail_analytics in editions/Bigquery/ and Snowflake reliable_pipeline in editions/Snowflake/kit/. dbt_project.yml files
[Observed] Blank-key policy (item 136): drop NULL, empty, and ASCII-space-only primary keys, and map blank foreign keys to NULL. docs/product-backlog.md
[Observed] scripts/scaffold_edition.py copies one edition into a new destination outside the starter repository. It refuses a destination inside the repository and one that already exists. It also copies scripts/first_run_preflight.py. A BigQuery workspace root is the dbt project. A Snowflake workspace keeps the edition layout, so its dbt project is <workspace>/kit/. No workspace marker file is written. Main checkout working tree (uncommitted), docs/edition-workspaces.md
[Observed] The migration loader and schema-drift modules are tracked in main after PR #5 and PR #6. The edition models/migration/ files, scripts/scaffold_edition.py, and its related script tests remain untracked in the user's working tree and are not evidence of shipped behavior. This revision does not re-derive the loader contract; that work belongs to n09-claude-scaffold-input-contract. git ls-tree -r 3bdceb6; working-tree status
[Observed] The execution order schedules 145 after 138 (temporal contract) and 139 (destination type profiles), and requires that operator edits are never overwritten. docs/product-backlog.md

2. Purpose and scope

The proposed scaffolder takes a reviewed mapping contract and generates a small, editable set of Silver starter models for one source and one edition. It runs locally and offline. It writes only into a client workspace [D1], and it writes only once for each source_id [D4]. Generated models produce only the canonical retail Silver columns [D2], so an operator can adapt them before connecting them to Gold.

Once published, generated files belong to the operator and may be edited freely. The scaffolder never reads, rewrites, renames, or deletes them again [D3, D4].

Non-goals: - Writing into the starter repository, its editions, or any published or packaged starter artifact [D1]. - Non-retail entities, extra tables, or non-canonical columns [D2]. - Updating, regenerating, merging, three-way diffing, or otherwise reconciling a published scaffold. There is no --update, --force, or merge mode [D3, D4]. - Cleaning up staging artifacts or stale output automatically [D4]. - Changing the shipped canonical Silver or Gold models. - Connecting generated models to Gold. - Deduplication or latest-row business logic. - CDC. - Schema-drift review. - Project renaming. - Running dbt or contacting any warehouse. - Applying mappings at load time.

3. Inputs

Input Contract
--edition bigquery or snowflake. Required. There is no default, and one invocation covers one edition.
--workspace Root of a client workspace created by the proposed scripts/scaffold_edition.py [D1]. That script is not yet tracked; see §8. This flag is required and has no default. It replaces the draft's --project-dir. The proposed dbt project directory is derived from the edition: <workspace> for BigQuery and <workspace>/kit for Snowflake. The run is refused before any write if any rule in Workspace checks fails.
--source-id A generic, non-sensitive alias only. Never use a client, customer, account, or source-system name. It must match ^[a-z][a-z0-9_]{0,39}$. It names the output directory and namespaces generated model names [U3].
--mapping-file An unchanged v1 mapping file, validated by the same validator module as the loader. A rule set the loader rejects must also be rejected here, with the same reason code [U4].
--inventory-file A saved dry-run inventory JSON for the same source and mapping. The scaffolder takes dlt-normalized Bronze table and column names from this file and does not reimplement dlt naming [U5]. Its bindings to the mapping, source, destination, and table selection are checked as described in §3.3 [R1].
--dry-run The plan-only mode described in §6.1. Without this flag, the run is a create-only generation (§6.2) [D4]. There is no --update flag. An unknown flag is a usage error and causes no writes.

3.1 Workspace checks

All checks run before any write and fail closed with the reason code shown [D1]:

Check Reason code
The resolved workspace, every resolved output path, and the staging path all lie outside the starter repository that contains the running scaffolder. This uses the same relative_to check observed in scaffold_edition.py. workspace_inside_starter_repo
The workspace and the derived project directory exist and are directories, not symlinks. workspace_invalid
The derived project directory contains dbt_project.yml, and its name matches the edition: retail_analytics or reliable_pipeline [U1]. edition_project_mismatch
<project>/models/migration/ exists. Its absence suggests that this is not a scaffolded edition workspace. workspace_missing_migration_models
The workspace does not contain the starter-repository layout, editions/Bigquery/ or editions/Snowflake/. This stops a copied or relocated starter checkout from posing as a client workspace [U24]. workspace_is_starter_checkout

3.2 Input rules (canonical retail scope) [D2]

  • Only the four canonical retail tables are scaffolded: customers, products, orders, and order_items. A mapping table outside this set is refused with non_canonical_table.
  • Every rule target must be a canonical column of its table. Otherwise, the run is refused with non_canonical_target.
  • Every canonical column of a selected table must resolve to either a mapping rule or an identically named Bronze column in the inventory. Otherwise, the run is refused with missing_contract_column.
  • The table's primary key is the canonical key: customer_id, product_id, order_id, or order_item_id. It is fixed by the canonical contract and is not read from the mapping, which has no key field. If the key does not resolve, the run is refused with missing_primary_key.
  • Non-canonical source and Bronze columns are never passed through, which keeps direct identifiers out of generated models. This follows from D2. The n09-gpt1-scaffold-privacy-review task must still confirm the column list and log content [U8].
  • Every Bronze identifier emitted into SQL comes from the inventory and must match a safe-identifier pattern.
  • Raw mapping source strings are never interpolated into SQL. Any identifier that fails the check is refused [U9].

3.3 Input bindings (gated on n09-claude-scaffold-input-contract) [R1]

The separate n09-claude-scaffold-input-contract task must define the fields, comparison rules, and reason codes for each binding below. It must also verify them with fixtures. This proposal fixes only what each binding must prove:

Binding Must prove Proposed reason code (final name set by the input-contract task)
Mapping fingerprint The inventory was produced with this exact mapping file. binding_mapping_mismatch
Source kind The dry-run report and completed manifest record the same supported source kind. This is a kind check, not a unique source identity check. binding_source_kind_mismatch
Unique source binding A non-sensitive operator-supplied source_binding_id is present and equal in the dry-run report, completed manifest, and invocation. The loader records this optional field only when supplied. Final generation refuses with scaffold_source_identity_unavailable when it is missing and scaffold_source_identity_mismatch when values differ; the field does not authenticate the underlying source. Matching schema, mapping, table sets, or source_id values must not substitute for it. scaffold_source_identity_unavailable / scaffold_source_identity_mismatch
Destination (target) namespace The Bronze namespace recorded in the inventory is the one that the generated migration_bronze_<source_id> source block will resolve to for the selected edition and target. binding_destination_mismatch
Selected tables The set of tables to scaffold equals the canonical tables in the mapping, and every one of them is present in the inventory. There is no silent partial selection. binding_table_selection_mismatch
(any) A binding field is missing or unreadable. binding_missing

The binding checks have these properties:

  • Privacy-safe reason codes. Binding refusals report only the reason code and, where needed, a canonical table name. They never echo fingerprints, source names or kinds, namespace names, Bronze names, or other values from the mapping or the inventory.
  • Gate. The core scaffolder may implement and ship plan-only behavior (--dry-run, §6.1). Until structural checks are defined and verified, it reports input_bindings_unverified. Even after that task lands, final generation refuses if source_binding_id is missing or differs across the reports and invocation [U27] [U31].
  • Earlier draft ID. This section replaces the earlier draft's U6, which compared only the mapping fingerprint.

4. Outputs

The published output lives only at <project>/models/migration/generated/<source_id>/ inside the client workspace [D1] [U10]. Before publication, the full output is assembled in an owner-only staging directory, <project>/.scaffold-staging/<source_id>/ (§6.2) [U28].

The scaffolder writes nothing else. Specifically: - It writes nothing in the starter repository. - In the workspace, it creates at most four things: - the staging root; - an empty per-source_id lock file in the staging root (§6.2 step 2); - the generated/ parent directory, if it is absent; - the published output directory.

File Content
silver_<source_id>__<table>.sql One model per mapped canonical table. Each model works as follows:
  • It reads the inventory's normalized Bronze column for each mapped field. When a load mapped a column in place, that normalized name is already the target, so the same reference still works [U11].
  • It applies the edition's cast expression to produce the canonical column name.
  • It applies the item 136 key policy.
  • It passes through _migration_run_id and _ingested_at_utc.
  • It emits only canonical columns and provenance columns [D2].
  • It contains no deduplication. A header comment says that the model is an editable starter and that deduplication semantics need a source row-version field [U12].
_silver_<source_id>__models.yml Model configs: Proposed enablement uses the migration gate in the untracked edition models/migration/ working tree, plus the tag scaffolded. The medallion tag is not applied, so the existing tag:medallion selection remains unchanged [U13]. The GPT-1 review flagged that generated models need explicit Silver materialization and schema config; that remains [U25].
Source definition: The scaffolder's own source block, migration_bronze_<source_id>. Its location must be checked against the untracked edition migration_bronze source files [U14].
Tests: not_null and unique on the canonical primary key. When both sides are generated, the YAML also adds relationship tests that match the canonical relationships: orders → customers, order_items → orders, and order_items → products.
.scaffold-manifest.json A provenance record written once, inside staging, before publication. It contains the manifest schema version, generator version, edition, source_id, and the mapping and inventory fingerprints. For each file, it lists the project-relative path and the SHA-256 hash of that file at publication time. It contains no row values, credentials, source URLs, or absolute paths. The scaffolder never reads or rewrites the manifest after publication. Its hashes only let the operator see what they have since changed.

All generated SQL, YAML, and manifest files are private client-workspace assets, and so is any staging directory left behind: - Permissions: The staging root and the staging directory are created owner-only (0700). Each file is created owner-only (0600) [U15]. Before publication, the staging directory is sealed to 0500 and each staged file to 0400 (§6.2). After successful publication, the scaffolder restores owner-writable modes (0700 directories, 0600 files) so the models can be edited immediately [U30]. If mode restoration fails, the complete output remains published; the run reports published_permissions_not_normalized, and the operator can repair it with chmod -R u+w <project-relative-output> before editing. - Distribution: Keep these assets out of public documentation, published sites, release archives, and the public starter repository. They may contain sensitive source-schema names even though they contain no row values. - Version control: If the client workspace is a git repository, the operator should add .scaffold-staging/ to the workspace .gitignore, so a leftover staging directory is not committed. The scaffolder does not edit project files (§5), so this is operator guidance only.

Output rules: - Edition cast expressions come from an edition-local template that matches the existing canonical Silver SQL [U16]. - timestamp, date, and numeric casts depend on items 138 and 139. Generated files carry a REVIEW header for these casts until those contracts are settled [U17]. - Output is deterministic: sorted tables and rules, LF line endings, and no timestamps in generated files.

5. Edition isolation

  • Code layout: The generator logic that parses mappings is shared and warehouse-neutral, and is proposed to live in migration/. SQL and YAML templates are proposed to live in each edition's folder: editions/Bigquery/scaffold/ and editions/Snowflake/kit/scaffold/ [U18]. Templates are read from the starter repository, and output is written only to the client workspace [D1].
  • Read and write scope: An invocation reads templates only from the selected edition and writes only inside that edition's derived project directory in the client workspace.
  • No credentials: The generator never reads profiles, environment credentials, or the other edition's files.
  • No project edits: The generator never adds dbt packages and never edits dbt_project.yml, sources.yml, shipped models, CI files, or profiles.
  • Default build unchanged: Generated models are disabled unless the migration target-and-variable gate is on, so an ordinary dbt build is unchanged.
  • Open question: Whether the generator itself ships inside client workspaces or release archives is undecided. Item 190 covers that question [U26].

6. Create-only generation and fail-closed protection [D3, D4]

Principle: The scaffolder never replaces anything. It publishes one complete, new output directory in a single atomic no-replace step, or it publishes nothing. Any doubt causes a refusal, including an existing path, a symlink, an unexpected staging artifact, a concurrent run, an unverified binding, or an unavailable atomic operation. Recovery is always a manual operator action. The scaffolder offers no force, update, merge, cleanup, or delete option.

6.1 Plan-only mode (--dry-run)

Plan-only mode runs every check in §3 and §6.3 that does not need a write. This includes the collision checks in §6.2 step 1, with the same collision precedence and the same reason codes that a default-mode run would give: symlink_refused, not_regular_file, output_exists, staging_in_use, and staging_exists. It also reports output_parent_invalid and staging_root_invalid (§6.3). To detect staging_in_use, it opens an existing lock file read-only and tries a non-blocking lock; it never creates the lock file. It then prints the planned project-relative file list and any refusal reason codes as JSON. It writes nothing, including no staging directory, and it never prints absolute paths.

A dry run never presents generation as ready. Its JSON status is refused when any reason code applies, and plan_only otherwise. It never reports a ready or success status, because staging, sealing, verification, and no-replace publication can only be proven during a default-mode run, and the workspace can change between the two runs.

This is the interface that the core scaffolder may deliver before the input-contract task lands [R1]. It reports input_bindings_unverified until structural checks are defined. Final generation requires the optional loader source_binding_id in both evidence artifacts and the scaffolder invocation; it refuses missing or mismatched values. This alias correlates operator-selected evidence but does not authenticate the source.

6.2 Generation and publication (default mode)

  1. Preflight. Run every check in §3 and §6.3, with no writes. Collisions at the output and staging paths are resolved by the collision precedence below. The scaffolder never inspects, compares, or touches the contents of an existing output. A customer who needs a new scaffold creates a fresh client workspace with scaffold_edition.py [D4].

Collision precedence. The same rule applies in preflight, in --dry-run (§6.1), and when a lost publish race is reported (step 5): 1. A symlink at any component of the output path or the staging path, including generated/<source_id> itself and .scaffold-staging/<source_id> itself, refuses with symlink_refused. The link and its target are left unchanged. 2. Any other non-regular, non-directory entry at those paths (for example, a FIFO or socket) refuses with not_regular_file. 3. An existing regular file or directory at the final output generated/<source_id>, whether populated or empty, refuses with output_exists and is left unchanged. 4. A per-source_id lock held by another process (step 2) refuses with staging_in_use. A live run owns the staging directory, so the operator must not remove it. 5. An existing real directory at .scaffold-staging/<source_id>, with its lock free, refuses with staging_exists. It belongs to an abandoned run and is not read, reused, overwritten, or removed. 2. Stage. - Create the staging root <project>/.scaffold-staging/ if it is absent, with mode 0700. - Lock. Open or create the lock file .scaffold-staging/<source_id>.lock relative to the held staging-root descriptor, using O_CREAT|O_NOFOLLOW|O_CLOEXEC and mode 0600, and validate it (§6.3). Take a non-blocking exclusive flock. If the lock is held, refuse with staging_in_use. - The lock is held for the whole run, through publication and mode restoration. - The kernel releases it on exit or crash, so a held lock always means a live run, and a free lock beside an existing staging directory means an abandoned one. - The lock file stays empty and is never deleted. - flock is advisory and coordinates only scaffolder runs. Its behavior on network filesystems is part of U29. - Create <project>/.scaffold-staging/<source_id>/ with an exclusive mkdirat and mode 0700. If it already exists, refuse by the collision precedence above. Open it with O_DIRECTORY|O_NOFOLLOW, hold the descriptor, and record its identity (st_dev, st_ino). The exclusive mkdir remains a second guard; the lock adds liveness. - Write every file, including the manifest, with exclusive create (O_CREAT|O_EXCL|O_NOFOLLOW) and mode 0600, relative to the held staging descriptor. - After writing each file, fsync it, then rehash it against the plan. A mismatch refuses with staging_modified. - fsync the staging directory. 3. Seal. - Through the file descriptors already held, set each staged file to read-only mode 0400. - Then set the staging directory to 0500, so no entry can be added, removed, or renamed in it without first changing its mode. - fsync the staging directory and the staging root. - Close every write descriptor. Permissions are checked at open, so a descriptor opened for writing before the seal would keep write access. - After sealing, an ordinary edit, add, delete, or rename inside the staging directory fails with a permission error. 4. Verify the sealed tree. Using directory-relative, no-follow operations on the held descriptors, confirm all of the following: - The entry set equals the plan exactly, and contains no subdirectories. Check this from the entry listing, not from the directory's link count, which some filesystems such as btrfs do not report as 2 + subdirectories. - Every entry is a regular file owned by the running user, with mode 0400 and link count 1. A higher link count means a hard link aliases the inode from outside the tree. - Every hash matches the plan. Re-hash through fresh openat(O_RDONLY|O_NOFOLLOW) descriptors, never through the earlier write descriptors. - The staging directory has mode 0500. - The staging root is still a real directory owned by the running user, with no group or other permission bits. - The name .scaffold-staging/<source_id> still resolves, without following links, to the held directory's identity.

Any difference refuses with staging_modified, and nothing is published. 5. Publish. - Output parent. Create <project>/models/migration/generated/ if it is absent, with mkdirat relative to the held migration/ descriptor and mode 0700. - EEXIST is accepted when the entry passes the output-parent checks in §6.3. A concurrent run for another source_id may have just created it. - Open generated/ with O_DIRECTORY|O_NOFOLLOW and hold the descriptor. A symlink refuses with symlink_refused, and a failed parent check refuses with output_parent_invalid. - Blocked on U32. On Linux, a non-root process cannot rename a 0500 directory into a different parent, because the moved directory's .. entry changes. The call fails with EACCES (confirmed locally; see U32). As currently written, this step therefore cannot succeed for ordinary users, and the step would map that failure to publish_permission_denied. Final generation stays blocked until the PO resolves U32. The remaining bullets hold under every U32 option. - Identity check. Immediately before the rename, fstatat the staging name relative to the held staging-root descriptor, without following links. If its identity differs from the held staging descriptor, refuse with staging_modified. - Rename. Move the staging directory to generated/<source_id> with one atomic no-replace directory rename between the two held parent descriptors on the same filesystem [U29]. On Linux this is renameat2(staging_root_fd, "<source_id>", generated_fd, "<source_id>", RENAME_NOREPLACE); on macOS it is renameatx_np(..., RENAME_EXCL). - Lost race. If the target appeared after preflight, the rename fails without replacing it. The run then refuses by the collision precedence above: symlink_refused for a symlink, and output_exists for a regular file or directory. Step 6 maps every other failure. - Post-rename identity. After the rename, fstatat the published name relative to the held generated/ descriptor. If its identity differs from the held staging descriptor, report publish_identity_mismatch. Do not restore modes or touch anything further, because the visible tree is not the one this run verified. - Durability. fsync both parent directories. Publication is the only step that makes output visible, and it is all-or-nothing. If either fsync fails, the output is visible but may not survive a crash. Report published_durability_unknown and continue with mode restoration. - Mode restoration. Restore owner-writable modes (0700 directories, 0600 files) through the held directory descriptor, which now refers to the published directory. Open each file with openat(O_RDONLY|O_NOFOLLOW) and fchmod it. The staged content is already complete and verified; this step changes permissions only, not content. - If it fails, leave the complete published tree in place and report published_permissions_not_normalized, with a relative output reference so the operator can run chmod -R u+w <project-relative-output>. - Release the lock only after the result has been written.

Supported guarantee. The seal-then-verify sequence protects against ordinary workspace edits and against concurrent scaffolder runs. - Ordinary edits are editors, scripts, and tools that respect permission bits. - Outside the threat model: a malicious or permission-changing process running as the same OS user can chmod and alter its own private staging directory between the final verification and the rename. The scaffolder does not claim to detect it. The guarantee also does not hold for root or other privileged processes, which bypass permission bits. - Residual race: the rename moves whatever entry has the staging name at that moment, and no primitive renames a held descriptor. The identity checks before and after the rename narrow this window and detect a swap after the fact, but they cannot close it. 6. Fail closed if atomic no-replace is unavailable. There is no fallback to a replacing rename, a copy, or per-file publication. - Preflight, before any write: - Different device IDs for the staging root and the generated/ location (or, if generated/ is absent, its parent) give staging_cross_filesystem. - A platform with no no-replace rename primitive at all gives atomic_publish_unavailable. - Only at the rename, after staging: some conditions appear only when the call is made, and no write-free probe can prove that the later call will succeed. These runs refuse with the codes below and leave the sealed staging directory in place, reported per step 7: - a filesystem that rejects the no-replace flag; - a kernel without the system call; - two mounts of the same filesystem, such as bind mounts, which share a device ID but still give EXDEV.

Rename failure Reason code
EEXIST, ENOTEMPTY Check the target without following links, then apply the collision precedence: symlink_refused, not_regular_file, or output_exists
EXDEV staging_cross_filesystem
EINVAL, ENOSYS, ENOTSUP, EOPNOTSUPP atomic_publish_unavailable
EACCES, EPERM publish_permission_denied
ENOENT, ENOTDIR staging_modified
Any other error publish_failed

Errno names and exception text are never printed (§6.4). 7. No automatic cleanup. - Failure before publication. If any step before successful publication fails, the staging directory is left in place, and the run reports its project-relative path, the reason code, and a staging_state. This includes interruption, staging_modified, a lost publish race, and a runtime rename failure. The staging_state values are: - unsealed: directory 0700, files 0600; - partially_sealed: some or all files 0400, directory 0700; - sealed: directory 0500, files 0400. - Signals. Signal handlers, for example for SIGINT and SIGTERM, may write the refusal result. They never unseal, rename, or delete anything. - Later runs. A later run for the same source_id refuses with staging_in_use while the lock is held, and with staging_exists once it is free. It does not report staging_state, because it does not read an abandoned staging directory. - Manual recovery. Only after a staging_exists refusal, never after staging_in_use, the operator inspects the staging directory. They then remove it from the project directory with chmod -R u+w .scaffold-staging/<source_id> && rm -r .scaffold-staging/<source_id>. This works for every staging_state, including an empty directory left by a crash just after the exclusive mkdir. - Crash before staging. A crash after the staging root or lock file is created, but before the exclusive mkdir, leaves only those empty, owner-only entries. A later run proceeds normally. - Failure after the rename. If post-publication permission normalization fails, the complete output remains in place with published_permissions_not_normalized, and the operator can restore write permission using the relative output path. - If the process is killed after the rename but before normalization, the complete output stays published with sealed modes and no result is written. Later runs refuse with output_exists. - The same repair, chmod -R u+w <project-relative-output>, restores editability. Whether a later run may add a hint about this is U33. - After success. After a successful publish, the empty staging root and lock file may remain. They are owner-only and hold no data. 8. After publication. Generated files are owner-writable immediately [U30] and may be edited, renamed, or deleted by the operator. The scaffolder never touches their content again; every later run with the same source_id refuses with output_exists, or with symlink_refused if the operator has replaced the output with a symlink [D3, D4].

6.3 Path and staging safety

  • Symlinks: Symlinks are not followed anywhere on the staging or output paths. This covers the workspace, the project, models/, migration/, generated/, the output directory, the staging root, the lock file, the staging directory, and every staged file. A symlink at any of these points causes a refusal with symlink_refused, and any other non-regular entry causes not_regular_file. Platforms lacking O_NOFOLLOW fail closed, consistent with the fail-closed approach recorded for item 123.
  • Held-descriptor walk: Preflight opens each path component with openat(parent_fd, name, O_DIRECTORY|O_NOFOLLOW), starting from the workspace root and continuing through the project, models/, migration/, generated/ (when present), and the staging root. It holds those descriptors for the whole run.
  • Every later create, chmod, verify, and rename is relative to a held descriptor. Paths are never re-resolved by name from the workspace root.
  • lstat checks alone are not enough, because a component could be swapped for a symlink after the check.
  • Output parent: An existing generated/ must be a real directory owned by the running user, with no group or other write bit. Otherwise, the run refuses with output_parent_invalid. When the scaffolder creates generated/, it uses mode 0700.
  • Staging location: The staging directory must be inside the client workspace project, outside the starter repository, and outside every dbt path configured in dbt_project.yml, using dbt defaults where unset. Those paths are the model, seed, test, analysis, macro, and snapshot paths. If any condition fails, the run refuses with staging_inside_dbt_paths. An existing staging root must be a real directory owned by the running user, with no group or other permission bits. An existing lock file must be an empty regular file owned by the running user, with mode 0600 and link count 1. Otherwise, the run refuses with staging_root_invalid [U28].
  • Path containment: Resolved paths must stay under the output and staging paths named above. Path traversal through --source-id is impossible by pattern and is also checked.

6.4 Error output

Error output contains reason codes, plus project-relative paths when needed. It never includes file contents, mapping source strings, bound input values, absolute paths, errno names, or exception text.

6.5 Default-mode result [U34]

A default-mode run prints one JSON object to stdout, with the same privacy rules as §6.4. It contains: - status; - reason_codes, a list; - stage: preflight, stage, seal, verify, publish, or normalize; - the project-relative output or staging path, where relevant; - staging_state, when a staging directory is left behind (§6.2 step 7).

When more than one condition applies, status is the most severe, and reason_codes lists every condition.

Status Meaning Proposed exit code
published Complete output published, durable, and owner-writable 0
refused Nothing is visible at generated/<source_id>. Any staging directory is reported. 2
published_permissions_not_normalized Complete output published, but some modes are still sealed 3
published_durability_unknown Complete output visible, but a parent fsync failed 3
publish_unverified publish_identity_mismatch: the visible tree is not the one this run verified 4

A usage error, such as an unknown flag, exits with 1 and writes nothing. In --dry-run, plan_only exits with 0 and refused exits with 2.

7. Offline acceptance criteria

These criteria apply to the future implementation. All of them run against synthetic fixtures in temporary directories and need no network, credentials, or warehouse. Workspaces are created in a temporary directory outside the repository with scripts/scaffold_edition.py, once that script is committed (§8). Failure injection uses a test-only hook defined by n13-claude-scaffold-core. The hook pauses or fails at named stages, so interruption and race tests are deterministic and do not depend on timing.

Permission, seal, and publication tests run as a non-root user. The suite fails, rather than skips, when the effective UID is 0, because root bypasses the permission checks under test. A root-only CI run would pass where ordinary users fail (§6.2 step 5, U32).

  1. Golden output: As a non-root user, the example mapping plus a synthetic inventory produce byte-identical expected files for each edition. A second run into a fresh workspace produces identical bytes. After mode normalization, every published file's hash still equals the manifest entry.
  2. Create-only refusal [D4]:
  3. Existing output: Each of these at generated/<source_id> is refused with output_exists: a populated directory, an empty directory, and a regular file. A symlink at generated/<source_id>, whether it points to a directory, a file, or nothing, is refused with symlink_refused, not output_exists. In each case, no staging directory is created, and the existing path's hashes and modes, and any link target's, are unchanged. When the staging root was absent beforehand, no staging root or lock file is created, and a hash snapshot of the whole workspace is unchanged.
  4. Dry-run parity: --dry-run must match a default-mode run on each collision fixture above, and also on: an existing staging directory, a symlinked staging directory, a lock held by a paused run (staging_in_use), and an invalid generated/ (output_parent_invalid). On each one, --dry-run reports the same reason code as a default-mode run, writes nothing, and reports status refused. No dry-run output, on any fixture, reports a ready or success status.
  5. Published modes: After atomic publication, assert owner-write modes (0700 directories, 0600 files). Injecting a chmod failure leaves complete content, reports published_permissions_not_normalized, and the documented repair command restores editability.
  6. Unknown flag: Passing --update is a usage error that causes no writes.
  7. Edits survive [D3]: After publication, the output is owner-writable without manual chmod; edit, add, and delete files under it. Every later run refuses with output_exists, and the edited tree's hashes are unchanged.
  8. Collision [D3, D4]:
  9. Concurrent runs: Run A is paused by the hook at each stage in turn. Each time, run B with the same source_id refuses with staging_in_use and changes nothing. After A publishes, B refuses with output_exists. Exactly one publication results, and it matches the golden output.
  10. Lock liveness: After run A is killed with SIGKILL at a pre-publication stage, run B refuses with staging_exists, not staging_in_use.
  11. Different source_ids: Two runs with different source_ids are paused so that both create generated/ at the same moment. Both publish. The run that loses the mkdir accepts EEXIST for the valid real directory.
  12. Staging swap: Run A is paused after its final verification and before the identity check. Its staging directory is renamed away and replaced with another directory of the same name. A refuses with staging_modified, and generated/<source_id> does not exist. Separately, a hook swaps the entry between the identity check and the rename. The run reports publish_identity_mismatch with status publish_unverified, and restores no modes.
  13. Race at publish: A directory or regular file created at the target between preflight and publish is not replaced, and the run refuses with output_exists. A symlink created there is not replaced, and the run refuses with symlink_refused. In each case, the target and any link target are unchanged, and the run's staging directory is left in place and reported.
  14. Interruption [D3, D4]:
  15. Injected failures before the rename: Inject a failure at each of these points:

    • after creating the staging root;
    • after creating the lock file;
    • after the exclusive mkdir, before the first file;
    • after each staged file write;
    • after each fsync;
    • between the file seal and the directory seal;
    • after sealing;
    • after creating generated/;
    • immediately before the rename.

    In every case, generated/<source_id> does not exist, so no partial output is visible. - Injected failures after the rename: Inject a failure at each of these points: - after the rename, before the parent fsync; - after the parent fsync, before mode restoration; - between individual chmod calls.

    In every case, the published output is complete and its hashes match the manifest. The reported status and modes match §6.5. A run killed at these points leaves sealed or partly sealed modes and writes no result. The documented chmod -R u+w repair restores editability, and a later run refuses with output_exists. An injected parent fsync failure reports published_durability_unknown. - Leftover staging: The staging directory is left in place. The failing run's staging_state matches the actual modes: unsealed, partially_sealed, or sealed. A later run refuses with staging_exists without reading, modifying, or deleting it. - Signals: SIGINT and SIGTERM at each stage before publication leave the staging directory's entries, modes, and hashes unchanged. Nothing is unsealed, renamed, or deleted. - Recovery: For each staging_state, including an empty staging directory left just after the exclusive mkdir, the documented command chmod -R u+w .scaffold-staging/<source_id> && rm -r .scaffold-staging/<source_id> removes it. A later run then succeeds. - Modified staging: Altering a staged file between its write and its post-fsync rehash gives staging_modified, and nothing is published. - Sealed staging: These tests run as an ordinary non-root user. After sealing, an ordinary attempt to edit a staged file, or to add a file to the staging directory, without changing modes fails with a permission error. Separately, a test hook makes the sealed tree differ before the final verification: it changes a file's content, adds an entry, adds a subdirectory, hard-links a staged file from outside the tree, or changes a mode. Each case gives staging_modified, publication is refused, and generated/<source_id> does not exist. No test asserts protection against a same-user process that changes modes between the final verification and the rename (§6.2 step 5). - Sealed-directory rename constraint: As a non-root user, on each supported filesystem, renaming a 0500 directory into a different parent fails with EACCES, and a same-parent rename succeeds. This pins the constraint behind U32. Whatever mechanism U32 selects, the golden publish must succeed as non-root. - No atomic rename, at preflight: A platform with no no-replace primitive gives atomic_publish_unavailable with no writes. A cross-filesystem staging root, detected by device ID, gives staging_cross_filesystem with no writes. - No atomic rename, at runtime: Each simulated rename failure leaves the staging directory in place and reported, and generated/<source_id> does not exist: - EINVAL or ENOSYS gives atomic_publish_unavailable; - EXDEV with equal device IDs (bind-mount case) gives staging_cross_filesystem; - EACCES gives publish_permission_denied; - an unmapped errno gives publish_failed. 6. Symlink and path refusal [D3]: - Symlinks: Place a symlink at each point listed in §6.3, including the lock file and a symlink planted inside staging during the run. Each causes a refusal with symlink_refused. No write goes through any link, and the link targets' hashes are unchanged. - Component swap: After preflight, a hook replaces a held path component, such as models/migration, with a symlink to a directory outside the workspace. No write lands outside the workspace, and the outside directory's hash snapshot is unchanged. - Invalid parents: A group- or world-writable generated/, or one owned by another user, gives output_parent_invalid. A non-empty lock file, or one with the wrong mode, owner, or link count, gives staging_root_invalid. 7. Input bindings [R1]: - Mismatches: Each of these is refused with its own reason code and with no staging or output: a mapping fingerprint mismatch, a source-kind mismatch, a destination namespace mismatch, a selected-table mismatch, and a missing binding field. - Unavailable source identity: Legacy dry-run and run-manifest artifacts may omit source_binding_id; those sets refuse final generation with scaffold_source_identity_unavailable. Descriptive schema/table matches do not count as identity. When supplied, mismatched IDs refuse with scaffold_source_identity_mismatch. - Privacy: Stdout and stderr contain no fingerprints, source names, namespace names, or Bronze names. - Gate: Before structural checks are delivered, a non-dry run refuses with input_bindings_unverified. After that, it still refuses with scaffold_source_identity_unavailable until the shared binding value is implemented. 8. Input refusals [D2]: - Refusal cases: Each of these is refused with no writes and its own reason code: a non-canonical table, a non-canonical target, a missing canonical primary key, a missing canonical column, an unsafe or absent inventory identifier, and an invalid source_id. - No extra columns: Generated SQL selects no non-canonical column, even when the inventory contains extra columns. 9. Validator parity: Every invalid-mapping fixture that the loader rejects is rejected by the scaffolder with the same reason code. 10. Edition isolation: A BigQuery run leaves unchanged a hash snapshot of the starter repository and of every non-generated workspace file. The only exceptions are the staging root, its lock file, and generated/. The same holds for a Snowflake run. 11. Parse check: - Migration target: Each client workspace, with generated models included, passes the existing offline dbt parse --profiles-dir .ci --target migration --vars '{enable_migration_models: true}'. The workspace scaffolder skips .ci/, so the test must supply a placeholder CI profile to the temporary workspace. - Default target: Under the default target, dbt ls lists no scaffolded models. - Staging ignored: dbt does not parse anything from a leftover staging directory [U22]. 12. Privacy: - Content: Generated files, the manifest, any leftover staging directory, and stdout/stderr contain no fixture row values, credentials, source URLs, or absolute paths. - Modes: - The staging root is 0700, and the lock file is 0600. - Published output directories are 0700, and files are 0600. - A leftover staging directory matches its reported staging_state: - sealed: directory 0500, files 0400; - partially_sealed: directory 0700, files 0400 or 0600; - unsealed: directory 0700, files 0600. - Alias: source_id is a generic, non-sensitive alias. 13. Workspace boundary [D1]: - Refusal cases: Each of these is refused before any write with its own reason code: - a workspace inside the starter repository, including editions/Bigquery/ itself; - a workspace that is a starter checkout copied elsewhere; - a symlinked workspace; - a missing or wrong-name dbt_project.yml; - a workspace without models/migration/. - Repository unchanged: The starter repository's hash snapshot is unchanged after each refusal. Where a workspace exists, its hash snapshot is unchanged too, and no staging root or lock file was created. 14. Optional render check: Rendering the generated SQL against the item 146 DuckDB fixtures yields the canonical Silver columns and key-policy results, once that harness exists [U23]. A pass is local evidence only and does not show warehouse parity.

Warehouse execution and cross-edition timestamp and numeric parity are not acceptance criteria for 145. They stay under 137, 138, and 139.

8. Gates before implementation

  • Baseline: The migration loader is now tracked (PR #5). Before generated-model integration, track and review the edition models/migration/, scripts/scaffold_edition.py, and relevant scripts/tests/ code that still exists only in the main checkout's working tree.
  • Casts: Settle 138 (temporal contract) and 139 (type profiles) first, or explicitly accept the REVIEW-marked cast placeholders [U17].
  • Plan-only core: The core scaffolder may proceed with the plan-only interface (§6.1) once the baseline is committed.
  • Final generation: Staging and publication (§6.2) stay disabled until all four gates land:
  • The PO resolves U32, which reconciles the sealed-until-publication rule with the Linux EACCES constraint on renaming a 0500 directory. Until then, §6.2 step 5 cannot succeed for a non-root user.
  • n09-claude-scaffold-input-contract delivers verified structural bindings (§3.3) [R1] [U27].
  • PR #7 (161e2d8) adds and tests optional source_binding_id recording in dry-run output and run manifests. The generator must require and compare it with the invocation [U31].
  • n10-claude-scaffold-atomic-publish-spike confirms the sealed no-replace publication mechanism and its fail-closed detection [U28] [U29].
  • Product boundary: D1, D2, D3, and D4 are recorded, which settles the product boundary that the draft gated on U7, U19, and U20.

Resolved by PO decision

Former ID Former question Resolution
U7 Restrict scope to the four canonical tables and canonical targets D2. Yes, with the reason codes non_canonical_table and non_canonical_target.
U19 Never overwrite operator edits, and refuse instead of merging D3. Yes, extended to every existing output, interrupted runs, and concurrent runs. There is no force or merge option.
U20 Journal-based --update with manual recovery D4. Withdrawn. The MVP is create-only, with no --update, no journal, and no orphan tracking.
U6 Inventory carries a comparable mapping fingerprint R1. Superseded by the broader input bindings in §3.3 and U27.
(path) Where output goes D1. Only the client workspace. --project-dir is replaced by --workspace with refusal checks.

Unverified decisions

Every open item is assigned to a named follow-up task.

Follow-up task Scope
n09-claude-scaffold-input-contract Loader, inventory, and source contracts, plus the input bindings (§3.3)
n09-gpt1-scaffold-privacy-review Column, log, and permission privacy
n09-gpt1-scaffold-dbt-gate-review dbt enablement, selection, materialization, and parse behavior
n09-claude-scaffold-cast-templates Edition cast templates
Item 190 review (n13-cursor-...) Generator packaging and inclusion
Item 146 DuckDB fixture harness
n10-claude-scaffold-atomic-publish-spike (Claude) Staging location, sealing, and the no-replace publication mechanism
n10-gpt2-scaffold-workspace-usability-review (Associate PO) Owner choices about workspace identification, published-output modes, and starter usability
n10-claude-scaffold-source-binding (Claude) Done in PR #7: add and test optional source-binding value in dry-run output and manifests
n13-claude-scaffold-core (Claude) Plan-only CLI behavior and offline contract tests; final generation stays gated
PO decision U32: the conflict between the seal and the rename, which blocks final generation
ID Decision Needed to verify Owner task
U1 Use the dbt_project.yml name to identify the edition Confirm that client workspaces keep the project name. Renaming is currently manual, and scaffold_edition.py does not rename. n10-gpt2-scaffold-workspace-usability-review
U3 source_id pattern and 40-character limit Check against BigQuery and Snowflake identifier limits after prefixing, using the item 133 naming matrix. The generic non-sensitive alias requirement is decided. n09-claude-scaffold-input-contract
U4 Import the loader's mapping validator instead of reimplementing it Confirm that it can be imported without side effects such as dlt initialization. n09-claude-scaffold-input-contract
U5 Require a saved dry-run inventory JSON for normalized names Confirm the dry-run JSON field names and that the output can be saved. n09-claude-scaffold-input-contract
U8 Non-canonical columns are never passed through This follows from D2. The review must confirm column and log content. n09-gpt1-scaffold-privacy-review
U9 Use a safe-identifier pattern instead of quoting arbitrary names Check against the item 133 naming matrix. n09-claude-scaffold-input-contract
U10 Output path under models/migration/generated/ Confirm that it inherits the migration enablement config in both projects. n09-gpt1-scaffold-dbt-gate-review
U11 Read normalized Bronze names in both "mapped at load" and "raw" modes Fixture test with and without --mapping-file at load. n09-claude-scaffold-input-contract
U12 Generate no deduplication; the unique test exposes duplicates Owner usability review. n10-gpt2-scaffold-workspace-usability-review
U13 scaffolded tag, with medallion excluded Confirm the gate and selection behavior in models/migration/. n09-gpt1-scaffold-dbt-gate-review
U14 Use one source block per source_id that mirrors migration_bronze Read models/migration/sources.yml once it is committed. n09-claude-scaffold-input-contract
U15 Owner-only permissions on staging, output, and files POSIX behavior test. Windows support is undecided; until it is decided, non-POSIX platforms fail closed. n09-gpt1-scaffold-privacy-review
U16 Edition cast templates copy the existing Silver casts Template review against the shipped Silver SQL. n09-claude-scaffold-cast-templates
U17 Ship with REVIEW markers before 138 and 139 are settled Owner sign-off. The backlog order prefers waiting. n09-claude-scaffold-cast-templates
U18 Shared logic in migration/, templates in the edition folders Confirm against AGENTS.md edition isolation and the packager's include list. Item 190 review
U22 Offline dbt parse and dbt ls work with generated sources in a client workspace and ignore leftover staging Run in an implementation branch. The placeholder .ci profile must be supplied because workspaces exclude .ci/. n09-gpt1-scaffold-dbt-gate-review
U23 DuckDB rendering of the generated edition SQL Depends on the harness. Item 146
U24 Detect a relocated starter checkout through the editions/ layout instead of a workspace marker file Owner choice. Alternatively, scaffold_edition.py could write a marker, but that would change the workspace scaffolder outside item 145. n10-gpt2-scaffold-workspace-usability-review
U25 Explicit Silver materialization and schema config for generated models This resolves the gpt1 review's high-risk finding. n09-gpt1-scaffold-dbt-gate-review
U26 Whether the generator ships inside client workspaces or archives Packaging decision. Item 190 review
U27 Structural binding fields, comparison rules, and reason codes for mapping, source kind, destination namespace, and selected tables Define and verify with fixtures, including privacy-safe output (§3.3, §7.7). Unique source identity is tracked separately in U31. n09-claude-scaffold-input-contract
U28 Staging at <project>/.scaffold-staging/<source_id>/, with its lock file .scaffold-staging/<source_id>.lock: inside the workspace, outside dbt paths, and on the same filesystem as generated/ Confirm the device-ID check and the dbt path exclusion in both editions. The dbt path exclusion is cross-checked by n09-gpt1-scaffold-dbt-gate-review. n10-claude-scaffold-atomic-publish-spike
U29 Atomic no-replace directory rename and fail-closed detection when it is unavailable Choose and test the platform mechanism.
Candidates: Linux renameat2(RENAME_NOREPLACE), which Python does not wrap, so it is called through libc or a raw syscall; and macOS renameatx_np(RENAME_EXCL), the directory-relative form of renamex_np.
Detection: Detection is partly runtime-only (§6.2 step 6). Confirm the preflight checks and the errno map, with no replacing fallback.
Test matrix: ext4, xfs, btrfs, tmpfs, overlayfs (Docker upper layer), 9p/drvfs (WSL), VirtioFS and gRPC-FUSE (Docker Desktop bind mounts), NFS, and APFS. Include two bind mounts of one filesystem (equal device IDs, EXDEV) and flock behavior on each.
Moved-directory permission: Moving a directory into a different parent also needs write permission on the moved directory itself, because its .. entry changes. See EACCES in the Linux rename system-call documentation. Confirm whether macOS enforces the same rule; the choice of mechanism belongs to U32.
Never publish an unverified tree.
n10-claude-scaffold-atomic-publish-spike
U30 Published output modes Resolved by PO: restore owner-writable 0700 directories and 0600 files after atomic publication so models are immediately editable. The staged tree remains sealed until publication. If permission normalization fails, leave the complete tree in place and report published_permissions_not_normalized with the documented chmod recovery. n10-gpt2-scaffold-workspace-usability-review (GPT-2 recommendation accepted)
U31 Shared non-sensitive source_binding_id in both dry-run output and completed manifests, supplied again to the scaffolder Implemented in PR #7 with privacy tests. The field is an operator binding label, not source authentication. Missing values still block final generation; stronger proof requires a source-native snapshot/version contract. n10-claude-scaffold-source-binding
U32 PO decision: how to publish a sealed tree when a non-root process cannot rename a 0500 directory into a different parent Confirmed locally on Linux 7.0 (ext4, uid 1000) with mv and Python os.rename: a cross-parent rename of a 0500 directory fails with EACCES, and a same-parent rename succeeds. This conflicts with D4 and the PO-resolved U30 text ("the staged tree remains sealed until publication").
Options:
(a) After the final verification, set only the staging directory back to 0700 through the held descriptor. Re-check the entry set and identity, then rename. Files stay 0400 through publication, and the threat model covers the extra window for ordinary tools only.
(b) Seal files only and never seal the directory.
(c) Stage in the same parent as the output. This avoids the .. update but puts staging inside the model path, contrary to §6.3.
The reviewer recommends (a). The spike confirms macOS behavior. Final generation stays blocked until this is decided (§8).
PO, with evidence from n10-claude-scaffold-atomic-publish-spike
U33 PO decision: whether a later run may check the published root's mode, without reading contents, and add a repair hint to output_exists when it is still sealed This covers a run killed between the rename and mode normalization (§6.2 step 7). D3 and D4 say the scaffolder never touches published content again; a mode-only check reads metadata only, but the PO must confirm that this is allowed. Until then, later runs report only output_exists. n10-gpt2-scaffold-workspace-usability-review
U34 Default-mode result object, status values, and exit codes (§6.5) Confirm the schema and exit-code numbers in the core CLI tests, including status precedence when several conditions apply. n13-claude-scaffold-core