Skip to content

feat(publish): personal cloud drive as an opt-in publish destination - #7199

Merged
chenmingwei23 merged 1 commit into
mainfrom
feat/personal-drive-publisher
Sep 5, 2026
Merged

feat(publish): personal cloud drive as an opt-in publish destination#7199
chenmingwei23 merged 1 commit into
mainfrom
feat/personal-drive-publisher

Conversation

@chenmingwei23

@chenmingwei23 chenmingwei23 commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

no linked issue: nothing here should close on merge. The two issues this body cites are cited for context, not as work this PR completes. #3599 is already closed and is the prior Artifact Deploy hardening it builds on. #8148 is the contract this PR deliberately defers work to, so a closing keyword on it would be actively wrong — merging this must leave it open.

1. What is the problem?

The artifact publish seam is fully built - a destination registry, provider-agnostic orchestration in publish_sync, a picker in the dashboard - but the public edition registers no destination. DefaultPublishRegistry.register_publish_providers() returned None, so get_provider() raised PublishUnavailableError and publishing was unavailable out of the box.

The one publish path that did work reached a different mechanism (an app-declared provider) and used one bucket plus one CloudFront distribution per artifact, keyed on the artifact slug. That shape has three consequences:

  • The link is dead when it is handed over. A new distribution takes minutes to propagate; before that the domain may not resolve at all, so the user is given a URL that shows nothing.
  • It does not scale. Each published artifact consumes an account distribution quota slot and pays a full control-plane create.
  • It can be auto-disabled. A brand-new distribution whose origin bucket is still empty is the shape a dangling-origin scanner flags. Some environments auto-remediate by disabling the distribution, which kills the link permanently and silently.

Registering a destination also made a second problem reachable for the first time. publish_sync had paths that only ever wrote a publication on success: a failed withdrawal cleared the local record anyway, and a visibility change never revisited the stored link. With no destination registered there was no publication to fail on, so none of it could be observed. This PR is what makes it observable, so it fixes it here rather than shipping a destination onto it.

2. Why this issue matters to the user

Publishing is how an artifact leaves the dashboard. Today, on a stock build, the answer is "unavailable"; and where a publish did work, the user's first experience is a link that does not load, with nothing in the product explaining whether it is still propagating, was disabled by a security control, or simply failed.

The withdrawal defect is worse than unavailability, because it is silent and it points the wrong way: the user pressed un-publish, the product said it worked, and the copy stayed public with no local record left to withdraw it.

3. How our fix solves it

A new module, src/kiro_crew/publish/personal_drive.py, implements the existing PublishProvider contract, and the public edition's DefaultPublishRegistry registers it. Registering it is not sufficient on its own -- see "The panel reads both registries" below. Six changes outside that module are part of this PR: three behaviour changes in publish_sync (withdrawal, the link a visibility change backfills, and an artifact delete now withdrawing its published copy), one optional field on PublishResult that lets a destination report a link that is not usable yet without losing the publication, two additive parameters on the deploy engine, and the panel wiring. No new interface method, no new publish parameter, no core branching.

The panel reads both registries. PublishHub fetched only GET /api/publish-providers, which returns app-declared destinations plus the core deploy row and documents that built-in destinations "are registered frontend-side and are not returned here" -- but no frontend list of built-ins existed. So registering a destination in the core registry made it reachable from the API and MCP paths and left the Publish panel showing nothing, which is most of what "publishing is unavailable out of the box" meant in practice. The panel now also reads GET /api/artifacts/publish-providers?kind=<kind> and merges the rows. Three details are load-bearing: an id claimed by an app row keeps the app row, because an app may declare a row under a core destination's id and the pre-existing resolution for that clash is app-first; a core row publishes through POST /api/artifacts/{slug}/publish, never through the app helper, whose no-endpoint fallback is /api/deploy/deploy -- the per-artifact deploy machinery this destination exists to replace; and a missing available flag counts as available, since the field is documented as omitted by older gateways.

A core row goes through the same consent gate, and offers no control it cannot honour. POST /api/artifacts/{slug}/publish returns no requires_confirm, so routing a core row through the panel's first step landed it directly in the success branch: content became world-readable on the first Publish click, and PublicPublishAckModal -- the blocking acknowledgment in front of every action that creates a publicly accessible website (#3599) -- never opened. A core row now enters the confirm step locally and publishes only from the acknowledged path; consent is about what is about to happen, not about a content digest, so needing no backend preview is not a reason to skip it. The TTL select is also hidden for a destination whose sharing_model declares no expiration support: the core publish route carries no TTL, so offering the control meant a user choosing "72 hours" received a persistent public link. For the same reason the acknowledgment's exposure sentence is now supplied by the caller: its default names recall and destroy, which are the deploy surface's actions, so a destination without them was telling the user their way out is something that does not exist for it. A core row says what actually ends the exposure: the link stays public until the published copy is withdrawn, and deleting the artifact requests that withdrawal. That wording went through two corrections, both worth recording because each earlier version named a control the user does not have: "private or unpublish" described the deploy surface's actions, and "until you delete the artifact" is false whenever the destination cannot be reached, since the delete then only requests a withdrawal that may not happen.

An unavailable destination is still listed, and explains itself. The core endpoint already keeps a destination visible while available is false as long as it is installable(), on the grounds that hiding it makes it undiscoverable until the user sets it up by hand. The descriptor now also carries install_hint, and the panel renders it in place: only a provider knows which action makes it available, and sending the user to the deploy setup page would describe a different destination's flow.

Pooled infrastructure. One private bucket and one distribution serve every published artifact; each artifact is a single object under a served key prefix. The first publish builds the drive, and every publish after it is an upload plus an invalidation. This removes the propagation wait, the per-artifact quota cost, and the empty-origin window that gets a distribution disabled. The cost of pooling is that every published artifact shares one origin, so they share browser storage; see section 5.

Prefix-scoped bucket policy, re-asserted on every publish. The bucket also holds private objects, so the shared engine's whole-bucket grant would serve them. The provider writes its own policy granting CloudFront read on the served prefix only, plus an explicit Deny on everything else. It is re-installed each time an existing drive is resolved, because both resources are tagged and therefore discoverable the moment they exist: had the policy failed once, the reuse path would otherwise hand out links to a bucket CloudFront cannot read - 403 for the life of the account, reported as success.

The distribution is created carrying its final tags. engine.create_distribution gains one optional tags argument; omitting it leaves every existing caller byte-identical (verified: the deploy suite passes unchanged). The drive passes its own tag set, so the distribution is never stamped with the deploy surface's site tag. Correcting the tags after the fact instead would need two more calls, a window in which the pooled drive is listed as a destroyable deploy site, and a cloudfront:UntagResource permission the shipped least-privilege policy does not grant.

Every published artifact gets an opaque origin. Pooling means one domain serves them all, and browser storage is keyed by origin, so without this one published document could read what another wrote to localStorage. These documents are mutually untrusted -- they are authored content, and the drive's owner is not the only author of what ends up inside them. The drive therefore attaches its own CloudFront response-headers policy carrying Content-Security-Policy: sandbox allow-scripts allow-popups: scripts still run, links still open, and storage is simply unreachable, so there is nothing to share. It must be a header -- the sandbox directive is ignored in a <meta> CSP by specification. This matches the posture the dashboard's own viewer already applies (it renders artifact HTML in an iframe without allow-same-origin), so no artifact loses a capability it had; the published copy was the one surface where the sandbox was missing. engine.distribution_config gains the optional policy id for this, additively, and the drive finds-or-creates the policy so a second drive or a retried create reuses it.

Only a first publish may create infrastructure. Push, sharing changes and withdrawal resolve an existing drive and report a missing one. Routing them through the create-capable path meant a discovery miss - a tag removed by hand, a transient answer from the tagging API - made a withdrawal build a fresh bucket and distribution, delete keys that were never in it, and report success while the original object stayed public.

Withdrawal is no longer best-effort at the seam. publish_sync.unpublish previously cleared the local publication whether or not the destination delete happened. That block is the only handle to a served copy, so it now raises and keeps it, leaving the withdrawal retryable. This is a behaviour change for every destination and every edition, not just this one: an un-publish whose destination call fails now surfaces an error where it used to report success. Deleting the artifact remains the way to drop a record for a destination that is gone for good, and that path does attempt the destination withdrawal first -- see the delete paragraph below for when it proceeds and when it does not.

A visibility change backfills a link the private publish could not derive. A destination whose link is derived rather than returned can only produce a usable one once the artifact is served, so a private publish stores none. Nothing else revisited the field, so turning an artifact public left it public with no link at all. The fill is additive - never replaces, never clears - so a destination that returned its own URL keeps exactly that URL.

Mutations on one artifact are serialized. Every mutating path is read-then-write against S3, and the seam does not serialize per artifact. A push landing between a move's copy and its delete had its bytes deleted with the source, leaving the record naming a version the drive no longer held and every later push in permanent conflict.

Error messaging for the states that produce a dead link. Both are invisible from the URL, so a publish that returned a link without mentioning them would look like a product that hands out broken links:

  • distribution not yet Deployed -> says it is still rolling out, that nothing was lost, and that the same link will work shortly;
  • distribution Enabled: false -> says the delivery network is disabled, that this is not a state the product sets, and what to do about it;
  • no registered account -> names the one action that fixes it;
  • an unregistered account name -> lists the registered ones.

An unreadable enabled-flag is treated as no objection, so a permissions gap does not masquerade as a disabled distribution.

Neither dead-link state may cost the withdrawal handle. Both are discovered only after the object is uploaded, and the orchestration records the publication only when publish returns - so raising there left a world-readable object with nothing to withdraw it by, while the message it raised claimed nothing was lost. A fresh distribution is InProgress for minutes after creation, which made that the normal outcome of a first public publish. PublishResult gains an optional notice, recorded as the publication's last_error, so the state is reported where destination trouble already surfaces and the handle survives. The field defaults to empty, so no other destination changes behaviour.

Discovery asks each API that documents support for the resource it is looking for. resourcegroupstaggingapi returns only resources "located in the specified AWS Region", and CloudFront's own documentation states Tag Editor and Resource Groups are not supported for CloudFront, listing only the CloudFront API's own tag operations. A single regional call therefore finds the bucket and silently misses the distribution -- and that gap is not harmless: discovery reports a half-built drive, which sends a HEALTHY drive down the partial-create recovery path, builds a SECOND distribution, and re-points the bucket policy at it, so every link already handed out answers 403; the second tagged distribution then makes discovery ambiguous permanently. The default region is not us-east-1, so this was the normal path rather than an edge case. The bucket is now resolved regionally, and the distribution is resolved by a tag query in us-east-1 with a fallback to CloudFront's own tag API. The tag stays the trust anchor in both paths - no origin-matching heuristic is introduced - and the recovery path still fires for the state it was written for, a bucket tagged by a run that died before its distribution existed.

Withdrawal is bounded, not merely submitted. Taking an artifact private or unpublishing it deletes the object and submits a CloudFront invalidation, and the invalidation is deliberately not awaited: one takes minutes, so awaiting it would block a user-facing withdrawal, and a timeout would force the caller to report failure after the object is already gone - the lossy shape this module is careful to avoid. That leaves cached copies as the exposure, so every upload now carries Cache-Control: max-age=300. Without it objects inherit the distribution's cache policy default (Managed-CachingOptimized, 24 hours), so an edge that had served a public artifact could keep serving it for a day after the user withdrew it and was told it was gone. The invalidation stays the fast path; the header is the guarantee.

Deleting an artifact withdraws its published copy. The delete handler calls publish_sync.delete_for_artifact BEFORE the local delete, using the publication it already read for its version capture. Before this, deleting a published artifact removed the local record and left a world-readable copy served with nothing left to withdraw it by. The ordering is chosen for its crash residue: die between the two steps in this order and the copy is withdrawn while the artifact remains, which the user simply deletes again; die between them in the reverse order and the record is gone while the content is still public. delete() removes the artifact directory by slug under the store lock rather than writing back a value read earlier, so a network round trip ahead of it widens no compare-and-swap window. Whether a destination failure blocks the local delete: it now always does, and this is a user-visible change to an existing route. An earlier revision of this PR split the two halves — a destination that could not be reached at all proceeded, on the grounds that it was the gone-for-good case and refusing would leave an artifact its owner can never delete. That split was withdrawn, because "cannot be reached" is not evidence of "is not being served": credentials expiring or a network partition looks identical to a bucket that no longer exists, and the copy may still be world-readable. So DELETE /api/artifacts/{slug} gains a new 502 outcome, in which the delete is refused and the publication kept, for both halves — a destination that answers and rejects the removal (FAILED), and one that cannot be reached or is not registered in this edition (UNREACHABLE). The record is the only handle able to reach a copy that may still be served, so it is not dropped on anything short of a confirmed withdrawal or a confirmed absence. The cost is worth stating plainly: a published artifact whose destination refuses or cannot be reached cannot be deleted until that changes, and a profile holding the publish permissions but not s3:DeleteObject is exactly that shape — no shipped IAM tier grants it yet (section 5). There is also no second way out: unpublishing keeps the record too unless a removal or a confirmed absence releases it. delete_for_artifact never raises either way; it reports which outcome occurred and the handler decides, so provider resolution and the availability probe failing on an unregistered destination cost a log line rather than an exception.

One registry key, opt-in rather than default, and no account picker. The edition registers exactly one destination, under the drive's OWN key rather than the seam's default key. That is what makes it opt-in: publish_sync resolves a publish that names no destination through the default key, which stays unregistered, so an unnamed publish gets the same 503 as an edition with no provider at all, while the picker still lists the drive and a caller can name it. This is a deliberate reduction from the original shape of this change, and the reason is stated in "What is deliberately not in this PR" below. Per-account keys were tried and withdrawn: a provider id is validated against ^[a-z0-9-]{1,32}$ at the HTTP boundary, so an account-qualified key renders a picker row that rejects its own submission, and list_providers does not dedupe, so a second key for one provider renders twice. A new publish therefore uses the profile registry's default; an existing publication pins the AWS account id that holds it into its external_id, and every mutation path verifies that pin before acting, so neither a moving default nor a repointed profile name can send a withdrawal to the wrong account.

Shared folders are deliberately absent. The provider declares the SHARING capability, which means it can change an artifact's visibility - not that it has a per-principal grant list. sharing_model declares supports_shared=False, so the UI never offers a grant list it cannot honour, and a request carrying recipients is refused explicitly rather than silently downgraded to world-readable. Adding shared folders later is a capability declaration plus its implementation, not a redesign.

4. What tests we did

test/test_publish_personal_drive.py - 96 tests, no AWS reached (the engine call surface is stubbed, so these assert the provider's own decisions). test/test_publish_sync.py gains coverage for the two seam changes.

  • Contract: the whole ABC is implemented; exactly one registry key is exposed; display_name is vendor-neutral.
  • Account resolution: default follows the registry; an unknown pinned name lists the registered ones; no account yields a remedy; available() provably makes no network call; a named-but-uncached account never borrows another account's domain.
  • Visibility: shared is refused not downgraded; a grant list is refused even with public visibility; private publish yields no browsable URL; going public backfills the link, and never replaces one the destination returned.
  • Upload: content type comes from the file being pushed rather than the remote's stale header, so an artifact re-saved under a different kind is not served under the previous type.
  • Concurrency: a missing stored digest counts as a conflict, not as permission to overwrite; a surviving copy in the other prefix never overwrites the newer one, in either direction.
  • Infrastructure: the distribution is born with the drive tag and never with the site tag; no CloudFront tag call is issued at all; a reused drive re-asserts its bucket policy; no path except a first publish provisions anything.
  • Withdrawal: never probes before deleting; is idempotent when nothing is there; a failed delete still purges the cache and still attempts the other key; the seam keeps the publication and raises through both of its failure mouths.
  • Discovery across regions: a drive whose distribution the tagging API does not report -- in the drive's own region, and in every region -- is still found whole and NOT rebuilt, while a genuinely missing distribution is still created against the existing bucket. The engine spy now models the real API's region scoping, which is what made the defect reproducible; two of the three tests fail against the previous discovery.
  • Post-upload reporting: an unreadable rollout state (the status probe itself failing) still returns the handle with a notice, so a throttle cannot strand a public object.
  • Delete path: the withdrawal is attempted BEFORE the local delete (order asserted), and both failure halves are pinned separately as REFUSALS -- test_a_reachable_withdrawal_failure_keeps_the_artifact_and_its_handle asserts a reachable rejection answers 502 and KEEPS the publication, and test_an_unreachable_destination_refuses_the_delete asserts the same for a destination that cannot be reached. That second test previously asserted the OPPOSITE and its docstring records the reasoning that was withdrawn. Withdrawal never raises through either mouth a wired caller can hit.
  • Dead-link states: a rollout in progress and an externally disabled network are each reported on the result rather than raised, the object is uploaded before either is read, and the handle comes back so the content stays withdrawable. At the seam, a notice is recorded as last_error while the publication is still stored.
  • Cache lifetime: every upload carries a bounded Cache-Control, so a withdrawn artifact cannot be served from an edge for the cache policy's 24-hour default.
  • Delete path: withdrawing from the destination never raises, through both mouths a wired caller can hit - a publication naming an unregistered destination (reported UNREACHABLE, delete REFUSED with 502), and a destination that refuses the removal (reported FAILED, delete REFUSED with 502). test_an_unregistered_destination_refuses_the_delete_and_the_handler_adds_no_guard also pins that the handler adds no guard of its own: a stub that raises propagates straight through it, so the decision genuinely lives in delete_for_artifact.
  • Panel behaviour (website/src/test/): the two registries merge with app rows winning an id clash; an unavailable row is listed and renders its remedy without being selected; a core row does NOT issue a publish request on the first click; no TTL control is offered for a destination that cannot expire a link.
  • Ratchet: the artifact store is never imported at module scope (an AST check), because doing so closes an import cycle through the platform registry.

Gates run on the changed files only: black --check, flake8, mypy, and the repo's own black-baseline gate all clean. The deploy suite (707 tests) passes unchanged, which is what establishes the create_distribution parameter as additive.

Not verified: no live AWS publish was performed from this branch, so the first real drive creation is still untested end to end.

Visual evidence. Recorded in an isolated pod with AWS_CONFIG_FILE pointed at an empty file, so the account list is empty and no real account data is on screen: temp-screenshots/pr7199-evidence.mp4 (18s, silent). The row label and the remedy text were read out of the DOM rather than eyeballed, and a request interceptor confirmed no publish request is issued before the acknowledgment.

The Publish panel listing the new destination with its remedy rendered in place

The clip predates the consent-copy fix in this round: its acknowledgment frame still shows the default persistent sentence naming recall/destroy, which is what the fix replaces for this destination. What the clip demonstrates - the destination appears, explains itself unconfigured, and cannot publish without the acknowledgment first - is unchanged.

5. Any other suggestions on the work

What is deliberately not in this PR

The publication-existence contract, and with it the default. Five review rounds on this branch converged on one question rather than five: what proves a publication exists, or no longer exists, and which paths may act on that. Answering it needs agreement across the publish engine, the artifact store and the folder store — three lock domains that do not nest, and whose cross-lock coordination this change set already declined once because of ordering deadlock. It also needs a positive absence probe, because resolving the destination by tag can only report that a lookup failed, never that a resource is gone.

Two windows in this PR depend on that contract and are therefore left open rather than patched again: a first publish can race a concurrent delete around the engine's per-artifact publish lock, which the delete paths do not take; and an unpublish against a drive that was deleted under a still-registered profile refuses instead of releasing the record. Both are recorded on this PR with the round table, and both are the same issue: #8148.

Holding the drive to opt-in is what makes that acceptable to ship: every one of those windows is now reachable only by someone who deliberately chose this destination, instead of by every user on the default path. The feature ships; the exposure does not. Flipping the key to the seam's default is the last step of the contract work, not a step of this change.

An earlier round declined the "keep it non-default" suggestion on the grounds that it removed this PR's purpose. That was right on the information available then. What changed is knowing that shipping it as the default requires the cross-store contract — so it is no longer the same trade.

A publication's handle pins the AWS ACCOUNT that holds it, not just the profile name. An earlier revision of this PR pinned only the name, and that was a real exposure rather than a nicety: a profile name is a local alias, so repointing it at another account and then deleting the artifact resolved the name to the new account, removed nothing, reported success, and cleared the record — leaving the original copy public with the only handle able to take it down erased. It needed no concurrency and no failure, just a config edit and an ordinary delete, which is why it is fixed here rather than deferred with the timing-dependent windows. The handle is now <key>~<account>~<profile>; the account sits in the middle and is recognised by shape (twelve digits), because a profile name may itself contain the separator, so appending would be ambiguous and counting separators would misread a pre-binding key~we~ird handle as account we and then refuse every operation on it forever. _profile_for — the one function every mutation path resolves credentials through — compares the pin against sts:GetCallerIdentity and refuses on a mismatch, and also refuses when the account cannot be read at all, since that call is gated by no IAM policy and acting on an unidentifiable account risks removing nothing while discarding the record. The account is read before the handle is minted, so a publish that cannot confirm it fails before uploading.

Other follow-ups

  • The drive can be created but never removed. Hiding it from the deploy surface is what stops a recall or destroy there from wiping every published artifact at once - but it also means no product action deletes it. After the last un-publish the bucket and distribution remain in the owner's account, costing money, removable only from the AWS console. A publish-surface teardown ("remove this drive, refusing while anything is still published") is the missing piece and should land near this, not open-ended.
  • No IAM tier covers the publish path, so "publishing works out of the box" holds for broad credentials only. The drive's bucket now sits inside the already-granted kirocrew-web-* namespace, but its public//private/ object prefixes and cloudfront:ListResponseHeadersPolicies / CreateResponseHeadersPolicy / ListDistributions / ListTagsForResource are outside the generated least-privilege policy in deploy/iam.py. A publish tier modelled on the existing _drive_statements() is the fix, and it should land with or immediately behind this change.
  • Per-publish account override. Naming an account per publish, rather than per provider id, needs the Protocol to grow an optional options argument. That touches an interface another edition implements, so it is deliberately left out.
  • Promote the deploy-engine helpers this module leans on. The provider calls engine._checked, _harden_bucket, _oac_name and _trimmed_stderr; a deploy-engine refactor could break publishing silently. Promoting them is a public-surface decision about that module and belongs with the publish IAM tier, which touches the same file.
  • The app-declared publish path still creates per-artifact infrastructure. This PR does not change it; the two mechanisms now coexist, and converging the app path onto pooled infrastructure is worth its own change.

@chenmingwei23
chenmingwei23 requested a review from a team as a code owner August 31, 2026 03:44
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 31, 2026
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ human override accepted

Human judgment by @chenmingwei23 overrides the GPT 5.6 finding for 362153a0ece58b1dcaf85d2e01cb0150b641de1a; the recorded reason is authoritative for this commit.

This comment is updated in place on each push.

The model was not re-run because an authorized human decision supersedes it.

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 362153a0ece58b1dcaf85d2e01cb0150b641de1a: <one-sentence reason>

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 31, 2026
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Design-level review of 362153a0ece58b1dcaf85d2e01cb0150b641de1a — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

The description's claims check out against the diff — the seam changes, the delete-refusal semantics, and the opt-in registration are all as stated. I have enough to render the design review.

Design-Verdict: CONCERNS

Sound, honestly-scoped design; the acknowledged gaps compose into a real user trap that ships live, not deferred.

Watch

  • Undeletable-artifact trap is reachable on day one. The delete handler now refuses on FAILED and UNREACHABLE ("gains a new 502 outcome"), unpublish refuses on the same evidence, DriveNotFound is "raised ONLY where absence is PROVEN. NO SITE RAISES THIS TODAY" — and no shipped IAM tier grants s3:DeleteObject. A user publishing with the credential shape the PR itself names gets an artifact with no exit until Define one publication-existence contract across the publish engine, artifact store and folder store #8148 lands. Consider landing the publish IAM tier with this PR rather than "immediately behind" it, since it is the one mitigation that prevents the trap instead of documenting it.
  • personal_drive.py calls deploy-engine privates (engine._checked ×15, _harden_bucket, _oac_name, _trimmed_stderr). A deploy refactor silently breaks publishing with no test on the deploy side owning that contract; promotion is deferred, the coupling merges now — at minimum pin it with a cross-module test until the promotion lands.
  • One-way real-world side effect: first publish creates a bucket + distribution no product action can ever remove (self-flagged); until teardown exists, every user who tries the feature once carries permanent AWS cost residue removable only in the console.

[DESIGN-REVIEWED] 362153a

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of 362153a0ece58b1dcaf85d2e01cb0150b641de1a — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All evidence is gathered: patch read in full, intent read, mechanisms and counts verified in the repo (raise DriveNotFound: 0 sites; refresh_publication at publish_sync.py:879 already owns "re-ask the destination, reconcile the record"; frontend notice reads: all boolean gates, text never rendered).

First-Principles-Verdict: CONCERNS

DriveNotFound ships with zero raise sites and self-described unreachable readers, and the new reprobe endpoint respells the existing publish/refresh reconcile path.

What this change ships

Intent: give a stock build a working publish destination (one pooled S3+CloudFront drive) and make withdrawal trustworthy — an ADDITION.

  1. Publish panel lists an opt-in "Public web (shared drive)" destination — justified
  2. Unnamed-destination publish still 503s; the drive never captures the default — justified
  3. Deleting a published artifact or folder now refuses unless the copy is withdrawn — justified
  4. Un-publish fails loudly instead of silently clearing the only withdrawal handle — justified
  5. Amber "link not live yet" notice beside the link and library icons — justified
  6. New "Check again" button + publish/reprobe-notice endpoint — duplicate of publish/refresh
  7. Image artifacts now refused by every publish provider (was a zero-byte object) — rides along
  8. Core rows get the consent modal, hidden TTL, corrected exposure wording — justified (invariant Artifact Deploy hardening: blocking acknowledgment modal, operator disable switch, docs and TTL #3599)
  9. Unconfigured destination stays listed with its own install_hint remedy — justified
  10. DriveNotFound typed absence signal — zero raise sites, both readers unreachable — zero consumers

Watch

  • The declared inventory undercounts: "Six changes outside that module are part of this PR" — the diff also ships a new HTTP endpoint (reprobe-notice), a per-slug publish serialization lock in publish_sync, and a seam-wide image-kind refusal; none is in that enumeration.

Subtractions

  • Delete DriveNotFound (publish_provider.py:198) and its two except branches (publish_sync.py:843, publish_sync.py:1119) — grep raise DriveNotFound: 0 sites, and the diff itself calls both branches unreachable; ship the type with the absence probe (Define one publication-existence contract across the publish engine, artifact store and folder store #8148) that makes it raisable.
  • Fold reprobe_notice into the existing refresh_publication (publish_sync.py:879, POST /api/artifacts/{slug}/publish/refresh) — one best-effort "re-check the destination and reconcile the record" route instead of two, dropping the new handler, route, and client function.
  • Shrink the persisted notice free text to notice_code alone: 11 frontend reads counted, every one a boolean gate or pass-through — the text is never rendered, all display copy comes from publishNoticeKey.

[FIRST-PRINCIPLES-REVIEWED] 362153a

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 362153a0ece58b1dcaf85d2e01cb0150b641de1a — this comment is updated in place on each push.

Review details

Blocking reachable_for subprocess runs on the gateway event loop during unpublish/delete.

FINDING — src/kiro_crew/publish_sync.py:833 — if not provider.reachable_for(external_id=art.publication.artifact_id): is called synchronously inside the async unpublish, while every other blocking op in the function uses await asyncio.to_thread(...); for a personal-drive publication with a pinned external_id, reachable_for_profile_for_live_accountengine._checked(["sts","get-caller-identity",...]) spawns a blocking aws subprocess (30s timeout) on the single gateway loop, stalling every other task (chat turns, liveness heartbeat) → Fix: await asyncio.to_thread(provider.reachable_for, external_id=art.publication.artifact_id). Same defect at src/kiro_crew/publish_sync.py:1101 in delete_for_artifact (and N-serial via _withdraw_subtree_publications); apply the same offload there.

[OPUS-REVIEWED] 362153a

Verdict parsed from the review's SHA-scoped output markers for commit 362153a0ece58b1dcaf85d2e01cb0150b641de1a.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 362153a0ece58b1dcaf85d2e01cb0150b641de1a: <one-sentence reason>

@chenmingwei23
chenmingwei23 force-pushed the feat/personal-drive-publisher branch from 4a2f293 to 5dc12d3 Compare August 31, 2026 04:51
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Aug 31, 2026
@chenmingwei23
chenmingwei23 force-pushed the feat/personal-drive-publisher branch from 5dc12d3 to 6211b22 Compare August 31, 2026 05:18
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Aug 31, 2026
@chenmingwei23
chenmingwei23 force-pushed the feat/personal-drive-publisher branch from 6211b22 to 62d7ff2 Compare August 31, 2026 05:37
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Aug 31, 2026
@chenmingwei23
chenmingwei23 force-pushed the feat/personal-drive-publisher branch from 62d7ff2 to 08b614a Compare August 31, 2026 06:05
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Aug 31, 2026
@chenmingwei23
chenmingwei23 force-pushed the feat/personal-drive-publisher branch from 08b614a to 0fdfc46 Compare August 31, 2026 06:19
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Note on the second finding at handlers/artifacts.py — the feature-map row. This is not a disposition record: that span is claimed by the atomic-withdrawal record above, and one span takes one record. This note is where the second finding is answered.

The finding is correct and I cannot act on it from this branch. docs/feature-map/README.md does not exist in my working tree, and neither does the gate that enforces it (scripts/check_feature_map.py). Both were added to main after this branch's base — I am 203 commits behind — so the map I am asked to update is not a file I have. GPT sees it because the lanes review the base+head merge ref, not my branch alone. That is the same mechanism that quietly retired this PR's fast-uri dependency-audit failure without any action from me.

Two things follow, and they point in opposite directions, so I am stating both rather than picking the convenient one:

  • The row is genuinely owed. The change adds a route and a dashboard handler, which is exactly the structural shape that gate is built to catch, and the map's purpose is to stay a trustworthy feature → page/handler lookup.
  • It is not currently failing anything. check_feature_map.py is wired into CI on main and is passing on this head; the two reds are the GPT lane and the readiness aggregate that follows it. By the gate's own documentation the structural half is automated and "the judgment half — is the row's content still true — belongs to the reviewer".

Writing the row requires rebasing onto main to obtain the file. I am not doing that unasked on a PR whose lanes are otherwise clean: a 203-commit rebase can conflict, and conflict resolution is the kind of thing that silently alters a reviewed diff. It also re-rolls every lane on a head that just settled. That is a maintainer's call, and it is cheap either way — the row is one line, and I will add it immediately after a rebase if you want one, or it can ride the rebase this PR needs before merge anyway.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author
  • "or unpublish first" recommends an operation that rejects the same unreachable destination (span=4499f6d61a01) — correct, fixed in b72afa6ce at three sites, one more than the two you named.

Confirmed by reading the operation it recommends. unpublish on an unreachable destination returns "This artifact's destination is not reachable, so it is still marked published… Deleting the artifact will not clear it either -- that refuses for the same reason." So the delete's own refusal message was pointing the owner at a second dead end, and worse, it framed that dead end as a way to accept the exposure and move on — an option that does not exist anywhere in this design today.

The gap was between prose and prose, not prose and code: DeleteWithdrawal's docstring already states this correctly and at length ("unpublish is not that escape hatch either, since it keeps the record unless a removal or a confirmed absence says otherwise"). The strings the user actually reads had not been brought along.

Three sites, and finding the third is the point. You named two; grepping the claim's phrasings rather than a symbol turned up a third — a comment in the folder cascade's keep branch describing the owner "withdrawing or unpublishing" its way out, which is the same false promise in the same state. All three now say the true thing: retry once the destination answers, and unpublishing first will not release it either.

A fourth hit was left alone deliberately. The provider-switch comment also says "require an explicit unpublish first", but that path is about switching provider on an artifact whose destination is reachable, where unpublish works exactly as advertised. Changing it would have been a false positive from my own grep, so I checked each hit's state rather than treating the phrase as the defect.

Gates by exit code: 310 tests across the handler, folder, publish-sync and corpus suites, plus flake8 / isort / black / brand / docs-lint all clean; only the two environment-owned failures remain. No test pinned the old strings — the two frontend hits for a similar phrase concern the acknowledgment modal's consent copy, and one is already a negative assertion.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author
  • Profile aliases can erase the withdrawal handle (span=515e29e3f4a6) — same finding as before, re-drawn on this head; the substantive answer is comment 5527686878 and it is unchanged. This record exists so the current head is not left without one.

Standing state, not re-argued: the finding is real and I agree with it. A handle pins the profile NAME, so repointing that name at another account and then deleting strands a public copy — with no race, no concurrency and no failure. The false claims that said otherwise were removed at four sites in 6091176ae.

The binding itself is deliberately not implemented here, because it changes what a publication handle IS: the separator is partitioned on its first occurrence so a profile name may contain it, which makes appending a field ambiguous, and six parse sites read the current shape. The design sketch, the choke point, and the note that sts:GetCallerIdentity needs no IAM permission are recorded on #8148 at data-loss severity, flagged as weighing against shipping even opt-in because this path needs no unusual timing.

Nothing has changed on it since; it is awaiting a maintainer's sequencing decision, not further work from me.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author
  • New handler is absent from the feature map (span=f7a17f6ef6ce) — correct and not actionable from this branch; this span's other finding, the non-atomic withdrawal, is FIXED in 7c197de9f and is no longer drawn.

This span previously carried two findings and could only take one record, so the feature map was answered in a plain note. The atomic-withdrawal finding it shared the span with is gone from this head now that the republish window is closed, so the map gets the record.

Why I cannot write the row. docs/feature-map/README.md and its gate scripts/check_feature_map.py do not exist in this branch's tree. Both landed on main after this branch's base — it is 203 commits behind — so the document I am asked to update is not one I have. The lanes see it because they review the base+head merge ref rather than the branch alone.

The row is genuinely owed, and it is currently failing nothing. check_feature_map.py is wired into CI on main and passes on this head; by its own documentation the structural half is automated while "the judgment half — is the row's content still true — belongs to the reviewer". Writing it requires rebasing onto main to obtain the file, and I am not doing a 203-commit rebase unasked on a PR whose diff is clean and verified: conflict resolution is how a reviewed diff changes without anyone noticing, and it re-rolls every lane. The row is one line and I will add it the moment a rebase is wanted.

Worth recording as a pattern rather than three separate items: three findings this session all reduce to the branch being 203 commits behind while the lanes review the merged tree — this map row, the fast-uri dependency audit that retired itself once main carried the fix, and the Backend Tests (Windows) shards, whose failures are five tests in a file that does not exist on this branch plus two notebook temp-staging tests that pass here. None is a defect in this diff, and one rebase settles all three.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

The finding is correct and it is a hole in a threat model this module already takes seriously. _sandbox_headers_policy's own docstring reasons about an attacker pre-creating a same-named policy without the sandbox CSP, and fails closed on it — so a drive is wired correctly the moment it is created. That defence says nothing about a drive that already exists: the policy object can be perfect and simply not be the one attached any more. Detach it, or repoint the default cache behaviour, and every artifact on the pooled domain is back to one real browser origin, which is the single cost of pooling the same docstring calls impossible to argue away. The reuse branch re-asserted the bucket policy two lines earlier and never looked at this one — that asymmetry was the defect.

Adopted with one change: refuse on a CONFIRMED mismatch only. Your fix says verify "otherwise refuse publishing". Refusing on an unreadable config would fail closed on a permissions gap, which this module deliberately forbids on the publish path — it would report "your distribution was tampered with" to someone whose IAM policy was merely narrowed, and fail every publish. So the check is the same third state the enabled flag already uses: attached / not attached / could-not-read, with only the middle one refusing.

Gated on link_promised, not require_serving. I reached for the latter first because its docstring describes exactly this distinction, then checked the call sites: there is only one, and it passes require_serving=False. Gating there would have been dead code that tested green.

Mutation-verified both ways, and which test caught it matters. Removing the check reddens only the two tamper tests. Failing closed on an unreadable read reddens the new tolerance test and the pre-existing test_unreadable_distribution_config_does_not_block_publishing — which is the evidence that the leniency was already a pinned contract rather than something I invented to justify a weaker check.

A correction I owe you separately. _distribution_enabled's docstring justified its leniency by claiming the shipped IAM tier does not grant cloudfront:GetDistributionConfig. That is false — the documented drive tier grants it, along with GetResponseHeadersPolicy and UpdateDistribution. The behaviour was right and the stated reason was wrong, which mattered here: "the tier cannot read it" would have been a reason not to build this check at all. Corrected, and the spec now documents both verification points.

Gates by exit code: 464 tests across the drive, publish-sync, handler, corpus and security-posture suites, plus flake8 / isort / black / loop-bound-locks / brand / docs-lint all clean; only the two environment-owned failures remain.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Dependency Audit / Audit Production Dependencies — read the job log, and it is neither of the two options. Posting here because the same failure is live on #7298 and the investigation should only happen once.

There is no advisory and no package. The log names no GHSA, no severity and no dependency. Both PRs fail on one line, byte-identical:

ERROR: production dependency audit failed closed: npm audit timed out after 120s for website/package-lock.json

So the gate did not find a vulnerability. npm audit never finished, and the check fails closed on a tool failure — correctly, but that is what is being reported as a dependency failure.

Neither PR introduced it, and neither PR's own dependencies are involved. #7199 touches zero lines of website/package-lock.json. What changed is on main:

main commit when (UTC) resolved packages in website/package-lock.json
1a765b88c 09-02 19:03 1028
27fd68e96 (#8041, sketch pad) 09-03 18:10 1112 (+84, +1094 lines)
b206a9f9f (#8040, social cards) 09-04 00:21 1113

The lane passed on this PR at 15:09:22Z with 1028 packages, and has failed on every run after 27fd68e96 landed: 23:28Z, 00:10Z (#7298), 00:34Z. An 84-package jump pushed npm audit --package-lock-only past AUDIT_TIMEOUT_SECONDS = 120 in scripts/check_npm_audit.py:17.

Classification: main-owned, and a tooling budget rather than a dependency. Not a new advisory on a main dependency either — that was the natural hypothesis and the log rules it out.

Two consequences worth being explicit about, because both differ from the obvious action:

  1. A re-run will not clear it. This is not a transient runner flake like the ##[error]fetch failed shard earlier tonight. The lockfile is bigger than the budget allows, deterministically, so every re-run burns ~2 minutes and fails again.
  2. A rebase will not clear it either. The lanes review the base+head merge ref, so both PRs are already auditing main's current lockfile — which is precisely why this appeared without either branch changing anything. Rebasing changes nothing about the input to the audit.

What actually fixes it has to land on main: raise AUDIT_TIMEOUT_SECONDS, cache the audit, or trim what #8041 added. Until then this lane is red on every PR that runs it, and neither PR should touch dependencies or add a .vulnerability-exceptions.json entry — there is no vulnerability to except, and an exception entry would suppress a gate that is working correctly while hiding the real problem.

I have not changed anything in #7199 for this and will not.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author
  • Public mutations bypass sandbox verification (span=515e29e3f4a6) — correct, and the gap was created by my own previous commit; fixed in 6c0039d56. Opus found the same thing independently and is answered in its own record.

You pointed at _require_drive rather than at a caller, and that was the right level. The check I added in be46ff63d sat on the first-publish path only. A private→public flip reaches the drive through _require_drive — a different resolver — so it began serving bytes on the pooled domain with no opaque-origin guarantee. Gating that guarantee on how an artifact happened to become public is not a guarantee.

The gate differs per method, and the difference is load-bearing rather than sloppy. In _require_drive, require_serving is the make-public signal — its own comment already said exactly that about the bucket policy ("the only caller that asserts the network is make-public, which is also the only one that ends by handing out a link") — so the check hangs off it there. In _ensure_drive the sole caller passes require_serving=False, so making the same choice there would have been dead code that tested green. Two different flags because the two methods use the same word differently.

Three paths stay ungated on purpose, under one rule: never refuse an operation that REDUCES exposure. Withdrawal and public→private are removals. push_version replaces bytes in place and hands out no new link — if the header were already missing, that artifact was already being served without isolation before the push, so refusing un-exposes nothing and merely strands its owner on the old bytes, which is the opposite of what someone replacing content wants. Each of the three now has a test pinning that it is not blocked, so a later tightening cannot quietly convert one into a refusal.

Mutation-verified: removing the new assertion reddens exactly one test, the private→public flip, and nothing else. Gates by exit code: 319 tests across the drive, publish-sync, corpus and security-posture suites, plus flake8 / isort / black / loop-bound-locks / brand / docs-lint all clean, zero failures. Spec updated — it had said a publish verifies the attachment, which was the same too-narrow claim as the code.

Note on the unrelated red: Dependency Audit is failing on a npm audit timeout, not an advisory, and the cause is on main — details and evidence in this note. Nothing in this PR touches dependencies.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author
  • update_sharing's make-public branch never re-verifies the opaque-origin header (span=76d2c69e413b) — correct and precisely stated; fixed in 6c0039d56.

You named the exact mechanism: update_sharing resolves through _require_drive(require_serving=want_public), which is a different resolver from the one a first publish uses, so the attachment check I added one commit earlier never ran on that path. Flipping private→public starts serving bytes on the pooled domain, which needs the same guarantee a first publish does.

Worth recording that you and the other lane converged on this independently from different entry points — that one pointed at the resolver, you at the caller. It is a good check on the finding being real rather than a lane artifact, and it is the second time in three rounds that the defect was an asymmetry: a sibling path protected, mine not. Here I built the asymmetry myself in the immediately preceding commit, which is exactly the failure mode of fixing a class of bug at one site.

Fixed at the resolver so it covers both entry points rather than at your call site, which would have left push_version and any future caller to be remembered individually. push_version is then deliberately left ungated — it replaces bytes in place and hands out no new link, so if the header were already gone the artifact was already served without isolation before the push, and refusing would only strand its owner on stale bytes. That path, plus withdrawal and public→private, each now carry a test pinning that they are NOT blocked.

Mutation-verified: dropping the assertion reddens exactly the private→public test. 319 tests green across four suites with flake8 / isort / black / loop-bound-locks / brand / docs-lint clean; the spec's claim was corrected from "a public publish verifies" to every transition into public serving.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author
  • Concurrent deletion can orphan a public upload (span=515e29e3f4a6) — real, verified in the code, and deliberately not fixed here: it is the publication-existence contract tracked on #8148. No code change. The second finding on this span is answered in a follow-up note.

Confirmed rather than assumed. The provider uploads at _put, and the engine persists the record afterwards at publish_sync.py:600 store.set_publication. Between those two, the object is public and no record names it — so a delete landing in that window sees publication is None, treats it as nothing to withdraw, removes the artifact, and the upload's object survives with no handle able to take it down. Your consequence chain is accurate.

Your fix is the right long-term answer and it is exactly what cannot be built inside this PR. "Reserve publication state before upload" means writing a record before the object exists. That record then points at something which may never appear — the upload can fail — and cleaning it up requires deciding whether a destination object exists, which is the other half of #8148. The two halves interlock, which is the reason that issue combines them rather than filing them separately. Reserving without the absence oracle trades an orphaned object for an orphaned record, and the record is the thing this whole change set treats as load-bearing.

The other half of your fix is already applied in substance. "Revert provider registration" — the drive no longer registers under the seam's default key. It registers under its own, so a publish naming no destination resolves the default key, finds it unregistered, and gets the same 503 as an edition with no provider at all. Reaching this race requires deliberately naming this destination. Full unregistration would ship nothing at all, which is a different decision from the one already taken.

Why I am willing to defer this one when I refused to defer the profile-alias finding on the same span: this race needs unusual timing — a delete must land inside a first publish's upload window. The alias defect needs none; a config edit and an ordinary delete suffice. Opt-in narrows who can reach a path, so it is a real mitigation for a race and a weak one for a defect anybody can trigger on the first try. That asymmetry is why one is deferred with the contract and the other is flagged as weighing against shipping at all.

Nothing changed in this PR for this finding, and nothing in it lets a delete drop a record it cannot prove is safe to drop — the delete path refuses on an unconfirmed withdrawal and, since 7c197de9f, refuses a republish landing in its own window.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Note on the second finding at personal_drive.py:299 — "Profile aliases can withdraw from the wrong AWS account". Not a disposition record: that span is claimed by the concurrent-deletion record above, and one span takes one record.

Unchanged and still awaiting a maintainer's sequencing decision. It has been drawn on four consecutive heads and answered each time — most fully in comment 5527686878, with the design sketch on #8148.

The short version, so this note stands alone: a handle pins the profile name, not the account behind it. Repointing that name at another account and then deleting resolves the name to the new account, removes nothing, reports success, and clears the record — leaving the original copy public with its only handle erased. Every claim that said the account was bound was corrected in 6091176ae.

Binding the account id is not improvised here because it changes what a handle is: the separator is partitioned on its first occurrence so a profile name may contain it, which makes appending a field ambiguous, and six parse sites read the current shape. sts:GetCallerIdentity needs no IAM permission, so unlike the absence probe this does not depend on the publish-tier work — it is sequencing, not feasibility.

I have flagged this one as weighing against shipping even opt-in, because unlike the race above it needs no unusual timing. That remains my recommendation and it is not mine to decide.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

@bolichen97 — you were right, and one part of your review was still unsatisfied until just now. Working through your four required changes against the current head 6c0039d56:

Done earlier:

  • The stale "that path does not notify the destination…" sentence is gone.
  • docs/system-specs/modules/artifacts.md no longer asserts the delete always proceeds; it now states the rule directly ("The local delete proceeds on ONE rule: only when there is nothing left to withdraw, or…").
  • test_a_destination_failure_never_blocks_the_local_delete is renamed and re-documented — it is now test_an_unreachable_destination_refuses_the_delete, and its docstring records that it previously asserted the opposite and why that reasoning was withdrawn.

Not done until now, and worse than the version you flagged. Your first item asked me to rewrite the section 3 delete paragraph and both section 4 "Delete path" bullets to state the actual split. I updated them to the split as it stood when you wrote — unreachable proceeds, reachable-rejection aborts — and then a later revision withdrew that split entirely: an unreachable destination now refuses too, because "cannot be reached" is not evidence of "is not being served". Expiring credentials and a deleted bucket look identical from here, and the copy may still be world-readable. I never revisited the body after that change, so it kept describing behaviour that had been deliberately reversed.

Concretely, three passages were wrong, and two of them named tests that do not exist:

Body claim Reality
§3: an unreachable destination "does NOT block the delete… the gone-for-good case" Both halves refuse with 502
§4: "test_an_unreachable_destination_still_lets_the_delete_through asserts an unreachable destination still deletes" That test is test_an_unreachable_destination_refuses_the_delete and asserts 502
§4: "unregistered destination (reported UNREACHABLE, delete proceeds)" + test_an_unregistered_destination_proceeds_and_the_handler_adds_no_guard Refused with 502; the test is ..._refuses_the_delete_and_the_handler_adds_no_guard

All three are corrected, and I added a check I should have been running from the start: every test_* name cited in the body is now verified to exist in test/. That audit is what surfaced the two phantom names — a claim can go stale silently, but a test name that no longer exists is mechanically checkable, and it was the cheaper signal all along.

Your framing was the accurate one and I want to acknowledge it specifically. You wrote that a reviewer reading sections 3 and 4 approves on the understanding that a delete can never be blocked by a remote system, and that the spec half ships to readers who never see the thread. That is exactly what happened again in the other direction: the body promised a weaker guarantee than the code provides, naming evidence that had been inverted. The behaviour has only ever moved toward refusing more, so nobody was told a delete was safer than it is — but a reviewer was being handed a description that no longer matched either the code or its tests.

Nothing about the behaviour changed in this update; it is body-only. The delete path's actual contract, unchanged since the withdrawal ruling: refuse unless there is nothing left to withdraw or the destination confirmed the withdrawal, and there is no second way out, since unpublishing keeps the record too unless a removal or a confirmed absence releases it.

Two open items are recorded and awaiting a maintainer decision rather than more work from me — the handle pinning a profile name rather than an AWS account (#8148), which I have flagged as weighing against shipping even opt-in, and the publish/delete window on the same issue. The Dependency Audit red is a main-owned npm audit timeout, not an advisory (#8334).

@bolichen97

Copy link
Copy Markdown
Collaborator

Open PR relationship audit

This is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion.

Relationship findings

  • PR #7818 is OVERLAPPING relative to this PR. The goals differ or the implementations can complement each other; this is not a duplicate claim. Recommended action for PR #7818: CONTINUE_DEVELOPMENT. Different user goals on shared files. Both should land; whichever merges second needs a small textual rebase in the exception block, the handler import block, and the ArtifactDetailPage banner stack. 7199 is also based on a newer merge base (63a043a) than 7818 (259db39). Files: src/kiro_crew/artifacts.py, website/src/pages/ArtifactDetailPage.tsx, src/kiro_crew/dashboard/handlers/artifacts.py.

No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Correcting my own note above, because following it would leave people stuck: the fix has landed on main, and a rerun-failed-jobs will never pick it up.

e786b086d (#8362, 2026-09-04 04:34Z) raised AUDIT_TIMEOUT_SECONDS to 180, added a retry for connection-level faults while keeping a real finding non-retryable, and moved the gate to workflow_call only — so it no longer runs on pull_request at all.

Why my PR is still red, and why re-running cannot fix it. I re-ran the audit on #7199 at 11:30Z, five hours after that commit landed. It failed with the old message, timed out after 120s. Both runs resolved the reusable workflow at the identical merge-ref commit:

05:11Z run   dependency-vulnerability.yml@refs/pull/7199/merge (9be1a638f3b4…)
11:30Z rerun  dependency-vulnerability.yml@refs/pull/7199/merge (9be1a638f3b4…)

refs/pull/<n>/merge is computed lazily — it was last recomputed at my 02:0xZ push, before the fix — and rerun-failed-jobs replays that same pinned SHA. So a rerun re-executes the pre-fix workflow no matter how long you wait.

Practical consequence, which reverses half of what I wrote earlier: I said neither a re-run nor a rebase clears this. The re-run half is now doubly confirmed and I can name the mechanism. The other half was wrong — any new push clears it, because that recomputes the merge ref against main's current tip, at which point the gate is workflow_call-only and simply does not run on the PR.

So the remaining red on a PR that has not pushed since ~04:34Z is a stale artifact of a workflow that no longer exists in this form, not a live signal. Nobody needs to re-run anything; the next push retires it. I am leaving #7199's red in place rather than pushing a no-op commit to refresh CI.

Thanks for the retry-and-budget work — worth noting your first suggestion turned out to be the right one for the electron-sized case, and the release-only move handles the large lockfile that a retry could not.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

@bolichen97 — re-requesting your review, because the change you reviewed is not the change that is here now. Laying out what moved so you can judge it rather than take my word for it.

1. The PR no longer makes this drive the default destination

It reviewed as "personal cloud drive as the default publish destination". It is now opt-in: the provider registers under its own registry key instead of the seam's default key, so a publish that names no destination resolves the default key, finds it unregistered, and returns the same 503 as an edition with no provider at all. The picker still lists it and a caller can name it explicitly.

This changes reachability, not correctness, and I do not want to overstate it. Three findings on this PR are still live on the opt-in path — they are not fixed by the downgrade:

  • a publication handle pins the AWS profile name, not the account behind it, so repointing that name and then deleting can strand a public copy. This one needs no unusual timing, and I have flagged it as arguing against shipping even opt-in.
  • a first publish uploads before the engine persists the record, so a delete landing in that window can leave a public object with no withdrawal handle.
  • deciding whether a destination object still exists has no positive probe, so absence cannot be distinguished from a failed look.

All three are lifted into #8148 as one publication-existence contract, because they interlock: reserving a record before upload needs the absence oracle to clean up a record whose object never appeared. Opt-in narrows who can reach them; it does not close them.

2. Your finding, and a second instance of the same class

Your four required changes are done, detailed in my earlier reply. The part worth repeating here is that the first one had been re-falsified in the interim: I rewrote the body to the split as it stood when you reviewed (unreachable proceeds, reachable-rejection aborts), and a later revision withdrew that split entirely — an unreachable destination now refuses too, since "cannot be reached" is not evidence of "is not being served". Three passages still described the old behaviour and two of them cited tests that no longer existed under those names. Fixed, and I now mechanically verify that every test name cited in the description resolves in test/.

Separately, and not something you raised: the same class of defect turned up in the acknowledgment copy, which told the user they could make the artifact private or unpublish it later — controls this destination does not offer. It now says the copy stays public until it is withdrawn. I mention it because it is the same failure mode you named, found by looking for more of it.

3. Automated validation, as facts rather than a conclusion

  • Two earlier heads, 3cf537e9c and 6091176ae, reached zero failing checks with PR Readiness: success.
  • The current head has two reds, and I believe neither belongs to this PR — but you should weigh that yourself:
    • Dependency Audit: the log shows no advisory and no package. It reads npm audit timed out after 120s for website/package-lock.json, and the gate fails closed on tool failure. It also hit #7298 at the same time. main fixed it in e786b086d (ci: run the npm audit gate on releases only; retry transient faults #8362) — budget raised to 180s, a retry added, and the gate moved to workflow_call only so it no longer runs on PRs. This PR's red persists because refs/pull/N/merge is computed lazily and a re-run replays the same pinned merge SHA, so it re-executes the pre-fix workflow; it clears on the next push. Details on #8334.
    • GPT 5.6 Review: this lane is not deterministic on this PR, in both directions. It printed no findings on a head that carried a defect it had itself marked BLOCKING one head earlier, where the only intervening change was docstring prose — and then drew two findings on code identical to that clean run after a description edit re-triggered it. Every finding it has raised has a written disposition; I am flagging the variance so a green or red from it is not read as more than a sample.

4. What I am asking

Please re-read the opt-in version. If your finding is addressed, clearing the CHANGES_REQUESTED would unblock it; if it is not, or if the three deferred items above are not an acceptable shape to ship even as opt-in, saying so is the more useful answer — that judgement is yours and I have deliberately not made it. I have not merged, approved, or overridden anything.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author
  • Optional publish stack loads on gateway boot (span=45e1de2a1142) — correct and fixed in 254b828cb; the other four findings on this head are answered in a companion note.

Verified rather than taken on faith, and the measurement is the reason I treated it as real. bootstrap.py calls ctx.publish.register_publish_providers() during platform bootstrap, before the socket is bound. The import was already deferred inside the method — but only to break a circular import, which does nothing for boot cost. Measured with -X importtime: kiro_crew.publish.personal_drive is ~526 ms cumulative, with kiro_crew.deploy.profiles alone at ~224 ms, because the module reaches the deploy engine's profile registry, the artifact store and the validation stack. no-new-work-on-gateway-boot-path is a written rule in this repo, and before this change register_publish_providers returned None, so the work is mine.

The fix was smaller than your suggestion implies, because the registry is already what you asked for. register_provider(name, factory) takes a callable and get_provider instantiates lazily and caches. So the instance was never built at boot — only the module import was eager. Bootstrap now registers a factory whose closure performs the import on first selection. The provider key is spelled in defaults.py so bootstrap does not need the module to learn it, with a test pinning that the literal still equals PERSONAL_DRIVE_PROVIDER so the duplicate cannot drift.

The test took three attempts and the two failures are worth stating, because each one produced a test that looked fine and proved nothing. (1) It first passed with the eager import restored: kiro_crew.publish stays imported with personal_drive bound as an attribute, so from kiro_crew.publish import personal_drive resolves the attribute and never enters the import system — the parent package's attribute has to be deleted too. (2) The fixed version then broke a pre-existing test, test_public_edition_registry_wires_the_drive, because re-importing the module created a second PersonalDriveProvider class and the older reference failed isinstance against instances of the new one; the teardown now restores the same module object. Both directions are mutation-verified: restoring the eager import reddens exactly this test, on its own message.

Also fixed here, from your ArtifactDetailPage.tsx:520 finding: the notice re-check wrote failures to setSaveError, the editor's save channel, whose surrounding copy tells the user their edit did not persist. Nothing was being saved, so that message sent them to re-save untouched content. The re-check now owns its own error state, rendered beside the button that triggered it rather than in the editor.

Gates by exit code: 538 tests across six suites, plus flake8 / isort / black / loop-bound-locks / brand / docs-lint / feature-map / tsc --noEmit all clean. Only the two environment-owned failures remain. black was run on the one file the gate named, and touched nothing else.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Note on the other findings on this head. Not a disposition record — the boot-path record above claims this lane's span, and one span takes one record.

PublishHub.tsx:352 — core publish failures bypass ErrorNotice. Held out of scope, with counts.

The rule is real and it is new: errors-use-error-notice is in website/AGENTS.md and was absent from that file at this branch's previous base — the rebase brought it in. So it now applies, and it applies to the whole component rather than to my path:

setResult({ error … }) sites ErrorNotice usages
main 7 0
this branch 8 0

PublishHub has never routed an error through ErrorNotice. I added one site to seven that ship today. Converting only mine would leave a component where the core-publish error uses the sanctioned component and the seven beside it do not — which is a worse state than the uniform one and is visibly the next finding. Converting all eight is a rewrite of a shipping component, from a PR whose subject is adding a destination, and it would need its own review of each error's hand-off decision. Recorded as component-wide and out of scope here.

publish_sync.py:555 — deletion during first publish can orphan public content. Verified real earlier and unchanged: the provider uploads, then the engine persists the record, so a delete landing between them sees no publication, removes the artifact, and leaves the object public. On #8148 with the absence oracle, because reserving a record before the upload needs that oracle to clean up a record whose object never appeared. Needs unusual timing, which is why opt-in is a genuine mitigation for this one.

personal_drive.py:299 — profile aliases can orphan public copies. Unchanged, and the one I have flagged as arguing against shipping even opt-in, because it needs no timing at all: repoint a profile name, delete, and the copy is stranded. Design sketch on #8148. Awaiting a maintainer's sequencing decision, not further work from me.

Both of the last two were raised and answered on earlier heads; they are re-drawn here because the rebase produced a new head, not because anything changed.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author
  • Failed request bypasses ErrorNotice (span=a1f47bacb15c) — correct, fixed in b8b55df2c; the other three findings on this head are answered in a companion note.

Fixed, and the measurement is why this one is a fix while the PublishHub instance of the same rule was not. ArtifactDetailPage already uses ErrorNotice twice and had zero hand-written red spans before this change — mine was the only one, so it is the asymmetry the rule exists to catch. PublishHub has seven setResult({ error … }) sites and zero ErrorNotice usages, identically on main, so converting only my one site there would leave the sanctioned component used once beside seven that ignore it. Same rule, opposite answers, decided by counts rather than preference.

It carries the {/* No hand-off: … */} comment your fix note asked for, with the reason rather than a placeholder: the panel sits beside an editable buffer that askAgent would destroy by navigating away, and a failed re-check is retryable in place by pressing the button again — so the hand-off could only cost more than it recovers.

Also fixed here: the feature-map row (server.py:1578). That row was owed and I said so when it was first raised — it could not be written then because docs/feature-map/README.md did not exist in this branch until the rebase brought it in. It now lists publish, unpublish, refresh, reprobe-notice and sharing, and names both components that drive them. Every endpoint was checked against the router rather than transcribed: a first draft of the row cited a publish/core route that does not exist, which is exactly the phantom-reference error the row is meant to prevent.

And confirmed gone: the profile-alias finding. The handle now pins the AWS account id and every mutation path verifies it — details on #8148. That was the one item I had flagged as arguing against shipping even opt-in, because it needed no concurrency; it is closed rather than deferred.

Gates by exit code: feature-map, docs-lint, tsc --noEmit, eslint on the changed page, and 168 tests all clean.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Note on the two remaining findings. Not a disposition record — the ErrorNotice record above claims this lane's span.

personal_drive.py:1205 — "unverifiable sandbox attachment fails open". Declining, and the reason is a permission asymmetry rather than a preference.

You are right about the mechanic: an AccessDenied on the distribution config makes _sandbox_policy_attached return None, and the publish proceeds. That is deliberate, and it is the opposite of the choice I made two commits ago for the account check, where an unreadable answer does refuse. The difference is which failure "unreadable" can mean:

  • cloudfront:GetDistributionConfig must be granted explicitly. It is in the documented drive tier, but a narrowed or older policy can omit it, and then the read fails permanently for a correctly-behaving install. Refusing there converts a permissions gap into a permanent publish outage and reports it to the user as tampering. test_unreadable_distribution_config_does_not_block_publishing pins that, and its docstring gives that exact reason.
  • sts:GetCallerIdentity requires no explicit grant — no policy narrowing can remove it, absent a deliberate deny. So there, an unreadable answer means credentials did not resolve at all, which is transient and retryable, and refusing costs nothing legitimate.

Requiring attached is True would therefore break a real class of install to close a case that needs both a narrowed policy and a distribution whose header was detached after creation. Worth stating the residue plainly rather than hiding it: for an install that cannot read its distribution config, a detached sandbox header is not detected. The isolation is still created correctly and fails closed at creation, so this is a post-hoc-tampering window, not a default-open one.

handlers/artifacts.py:2104 — "generation check is not atomic with destructive clearing". Real, and on #8148 with the rest of the family.

Your chain holds. The created_at comparison happens before the clear, and delete() removes by slug, so a replacement created after that comparison is deleted or has its publication cleared. The republish case is already refused — delete(refuse_if_published=True) re-reads the record inside the same lock as the removal — but that catches a published replacement, not an unpublished recreate.

Your fix is the right shape and it needs a capability the store does not have: "clear this publication and delete only this generation" means delete() must accept an expected generation and compare it under its own lock. That is a store API change, and it interlocks with the two items already on that issue — reserving a record before an upload needs the absence oracle to clean up a record whose object never appears. Reaching it requires a delete and a recreate to overlap inside one network round trip, which is what separates it from the account-binding item I did fix here, where a config edit and an ordinary delete sufficed on the first try.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Status summary

Posting a state snapshot rather than a change, so this is scannable without reading the thread.

What it delivers. The public edition registers no publish destination, so publishing an artifact returns 503 out of the box. This registers one: a private S3 bucket plus one CloudFront distribution in the operator's own AWS account. It registers under its own key rather than the seam's default, so it is opt-in — the picker lists it and a caller can name it, but a publish that names no destination still resolves the unregistered default key and still gets the same 503. Shipping it as the default needs the contract below; that is why it is not the default here.

Three findings are lifted out, not fixed. They are one question wearing three faces — what proves a published copy exists, or no longer exists, and who may act on that — so answering them separately means answering the same question three times with three different rules. They are tracked together as one publication-existence contract in #8148:

  • a first publish uploads before the engine persists the record, so a delete landing in that window leaves a public object with no withdrawal handle
  • there is no positive probe for whether a destination object still exists, so absence cannot be told apart from a failed look
  • the delete path compares an artifact generation before clearing, but removes by slug, so an unpublished recreate landing after the comparison can be deleted

All three need overlapping operations to reach. A fourth item of the same family did not — a handle pinned the AWS profile name, so repointing that name and deleting stranded a public copy on the first try, with no concurrency — and that one is fixed here rather than deferred: the handle now pins the account id and every mutation path verifies it before acting.

Real defects this found and closed, which is the part worth weighing against the diff size:

  • the notice re-check turned a read-only control into a PutBucketPolicy write — a "check the current state" button was mutating the bucket policy. Pinned by test_serving_notice_never_writes_a_bucket_policy.
  • update_sharing flipping an artifact private→public did not verify the opaque-origin header. The drive pools one distribution across every artifact, so without that header one published document can read another's origin-keyed storage. The first publish checked it; the flip reached the drive through a different resolver and did not.
  • the per-artifact publish lock guarded only the first-publish branch, but the decision between "first publish" and "re-publish" is itself the thing being raced — a guard around the branch it selects is read before the lock and acts after it. It now wraps the whole operation.
  • a delete whose destination withdrawal was not confirmed used to drop the publication record anyway, which erased the only handle able to take a world-readable copy down. It now refuses.

Automated validation. On the last fully settled revision (cea8f9e3c): 65 lanes, 60 green, 0 pending, and one red — GPT 5.6 Review. The current head adds two small changes (routing a re-check error through ErrorNotice, and the feature-map row) and is still rolling.

One fact about that red, offered as a fact rather than a conclusion: this lane is not deterministic on this PR. It printed no findings on a revision carrying a defect it had itself marked BLOCKING one revision earlier, where the only intervening change was docstring prose; it then drew two findings on code identical to that clean run after a description edit re-triggered it; and it has re-run and flipped its verdict on an already-settled revision. Every finding it has raised has a written disposition in this thread. Whether that makes its current verdict actionable is a reviewer's call, not mine — I have not re-run it to chase a greener sample, because a re-run can also destroy a real finding.

Size is the honest obstacle: 41 files, +8120/-105. It is one commit and the history is linear if that helps to read it.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Addendum on the two Backend Tests … (4) reds, so they are not read as this PR's.

Both failed inside test/test_slot_close_recreation_race.py. That file does not exist on this branchmain added it today at 17:07Z in c477f0b9d (#7212) — and this PR touches none of the modules it exercises: its imports are autonudge, members, dashboard.chat_handlers and dashboard.state, while the diff here is confined to the artifact store, the publish seam and the dashboard's artifact handlers. Its _publish_metadata references are slot-transcript metadata, not the artifact publish path.

They are also not deterministic, which is worth stating because it decides whether a rerun is honest or wishful. Two runs of the same job on the same commit failed on different sets: test_cleanup_failure_arm_does_not_clobber_replacement dropped out and test_cleanup_handover_write_failure_is_reported_failed plus test_cleanup_ordinary_archive_still_saves_and_removes appeared. A code fault reproduces the same set. These are race tests with 120-second timeouts, so a loaded Windows runner is enough.

I re-ran the failed jobs once on that basis. Coverage Gate cleared with them — it fails closed on the backend result rather than measuring anything itself. Linux shard 4 passed and has since been re-queued; Windows shard 4 failed again on a different subset.

Current state: 63 lanes, 56 green, 1 queued, 2 red — Windows shard 4 above, and GPT 5.6 Review as described in the previous comment.

I have deliberately not pushed anything to refresh CI. A push recomputes the merge ref and re-rolls all 63 lanes, discarding green results to re-sample them, which trades a real signal for a fresh gamble. Nothing here is a code change.

The publish seam shipped with no destination registered in the public
edition, so publishing an artifact was unavailable out of the box. Register
a concrete destination -- a private bucket plus one CloudFront distribution
in the operator's own account -- through the existing platform registry.

One pooled distribution serves every published artifact. A distribution per
artifact pays a cold create on each first publish (the URL does not resolve
until it propagates), consumes an account distribution quota slot per
artifact, and presents a new distribution over a still-empty origin, a shape
scanners flag as a dangling origin and may auto-disable.

The drive carries its own tag rather than the deploy engine's site tag. The
engine groups sites by that tag and skips resources without it, so tagging
the pooled drive as a site would list it in the deploy surface as an
ordinary one-off site, where a recall empties it and a destroy deletes it --
one action on an unfamiliar row would wipe every artifact ever published
here. Discovery, with its ownership and ambiguity hardenings, is implemented
against the drive's own tag instead of inherited. The engine hard-codes its
site tag on the create call, so the drive's tag is added and the site tag
removed afterwards; every later publish re-attempts that removal, which is
what keeps a failed attempt from leaving the drive listed as a site forever.

A publication's account identity is bound into its external id at publish
time. The seam persists a provider registry key and re-resolves it later, so
a key meaning "whatever account is default right now" would make a later
unpublish run against a different account -- reporting success while the
original object stayed served. The public URL is built from the id's random
half only, so an account label never appears in a link.

There is one registry key, the seam's default. Per-account keys were tried
and withdrawn: a provider id is validated against ^[a-z0-9-]{1,32}$ at the
HTTP boundary, so an account-qualified key renders as a picker row that
rejects its own submission, and list_providers does not dedupe, so a second
key for one provider renders twice. Choosing an account per publish needs a
real publish argument on the shared seam and is left to that change.

Removal and take-private are never gated on the delivery network being
healthy. The orchestration clears the local record whether or not the
destination delete happened, so refusing there would report a withdrawal
that never occurred while the content stayed public. Publish and make-public
do assert it, because those hand out a link.

Withdrawal is no longer best-effort at the seam either. The publication block
is the only handle to content that may still be served, so a failed removal
keeps it and raises instead of clearing it and stranding the remote copy;
deleting the artifact remains the escape hatch for a destination that is gone
for good. A visibility change that turns an artifact public also fills in a
link the private publish could not derive, additively, so a destination that
returned its own URL keeps exactly that URL.

Every step that can leave the remote and the record disagreeing is ordered
against which failure is worse. A head-object failure is no longer read as
"object already gone": only a confirmed not-found is an absence. On the way
out to public the source delete and the cache purge are cleanup, because the
object is already served and raising would record it as private; taking
something private both are load-bearing and a failure is reported. When both
prefixes hold a copy the destination wins and is never overwritten from the
stale source, which may predate a push.

Pushes honour the concurrency token the provider declares: the content digest
is stored as object metadata and compared before writing, a remote change
made out of band returns a conflict instead of being overwritten, and a
MISSING digest counts as a conflict too, since every object this module
writes carries the metadata. A push types its bytes from the file it was
handed rather than the remote's old header, so an artifact re-saved under a
different kind is not served under the previous type.

Shared folders are not implemented. The provider declares the SHARING
capability, which means it can change an artifact's visibility -- not that it
has a per-principal grant list. sharing_model declares supports_shared=False
so the UI never offers one, and a request carrying recipients is refused
explicitly rather than silently downgraded to public.

A same-named response-headers policy is verified before it is reused. The policy
is account-global and found by a fixed name, so a name match alone was enough to
attach it -- and this product runs agents holding the operator's own AWS
credentials, so a policy of that name can exist without the sandbox CSP. Reusing
it would hand every published document a real browser origin, which is the one
cost of pooling the module refuses to pay. A name match now reuses only when the
policy's CSP equals the sandbox directive exactly with Override set; anything
else raises and names the remedy, because the name cannot be reused and cannot
be recreated automatically. Independently of any attacker, never comparing the
config also meant a later change to the directive would never reach an account
that already held the policy. The directive is spelled once and read by both the
create and the verify path, so the two cannot drift; the list call already
returns each policy's full config, so verification adds no request.

A publish that succeeds but cannot serve its link yet is no longer reported as a
failure. The rollout notice was stored in the publication's last_error, which
every consumer reads as failure: the first publish to a fresh drive rendered red,
the link was withheld from the success branch that renders it, and a danger
banner persisted on a healthy artifact until an unrelated push cleared the field.
The notice now has its own field, empty by default so existing records load
unchanged, and it is cleared wherever a later success settles the question. The
link renders with a neutral line beside it instead.

Two states in the publish panel that could only end in failure are closed. An
unconfigured core destination offered a live Publish button, so a stock install
could read the remedy, publish anyway, pass the exposure acknowledgment and only
then fail; it now shows the remedy and a way to set the destination up, and no
Publish. The empty state was not gated on either provider query, so a stock build
announced that no destination existed before the core one arrived -- the exact
false message this change exists to remove.

Discovery that cannot see no longer reports absence. Enumerating the account's
distributions, or reading one distribution's tags, could fail on a throttle and
be swallowed as "nothing found" -- and the caller does not refuse on nothing
found, it creates: a second distribution over the existing bucket with the
bucket policy repointed at it, so every link already handed out answers 403,
and a second tagged distribution that wedges each later discovery on the
ambiguity refusal. A transient error became permanent breakage. Both sites now
raise and say nothing was changed, so the next attempt simply succeeds. A
genuinely empty result still means absence and still creates. Withdrawal is not
affected: that path never creates, so raising there is the same retry it is
everywhere else.

Deleting an artifact whose withdrawal failed now depends on whether a retry
could work. The publication is the only handle able to withdraw a copy that may
still be served, so discarding it on any failure strands that copy with nothing
able to reach it. Refusing on any failure is not the answer either: the strict
withdrawal path tells the user to delete the artifact when a destination cannot
be restored, so refusing there would leave an artifact that can never be
deleted and turn that instruction into a lie. A destination that cannot be
reached at all is that gone-for-good case and the delete proceeds; a
destination that answers and then rejects the removal is retryable, so the
delete stops and keeps the record. The withdrawal reports which of those
happened rather than swallowing everything.

A publish notice now says which state it is reporting, because the interface was
asserting one it could not know. A notice was free text and both render sites
threw it away to print a single fixed line promising the link would work in a few
minutes. That is true while a distribution propagates and false when one is
disabled, where the link never resolves until a person re-enables it -- so the
copy sent the user away to wait for something that was not going to happen, and
the only action that fixes it was never offered. The provider now returns a code
beside the text, the record keeps it, and each case gets its own wording;
anything the interface does not recognise falls back to a line that promises no
time at all.

A stale notice can now be cleared, because nothing ever cleared it on the
ordinary path. It was recorded once at publish time and reset only by a push, a
pull, an overwrite or a visibility change, so a link that had finished rolling
out kept its warning on every later visit. The notice is re-checked against the
destination on request and cleared only when the condition really has cleared --
asked for rather than polled, since the answer costs a call to the destination
and a timer would either clear it without looking or ask on every visit.

The consent copy names a way out that exists. It promised the reader could make
the artifact private or unpublish it; both of those calls are defined and reached
from nowhere in the interface, so it named two controls a user cannot press. It
now names deleting the artifact, which they can.

A half-present drive is no longer read as no drive. A tagged distribution whose
bucket is missing was reported as absence, and absence is what sends the create
path into partial-create recovery: a second tagged distribution over a fresh
bucket, and discovery wedged on the ambiguity refusal from then on. It now raises,
naming what was found and what is missing; a drive that is genuinely absent still
creates. Nothing tries to repair the half-present case, because which half is
authoritative is not something this module can know.

The drive answers the re-probe itself, because the seam's default answer is "I
cannot look" and that would have made the whole reconcile a no-op for the one
destination that produces these notices: the control would call it and the stale
warning would never clear. The provider re-resolves its own drive and re-derives
the notice, and every way that can fail answers "could not establish" rather than
"cleared" -- inferring the latter from a failed look would drop a warning that is
still true.

Reachability is asked about the publication rather than the destination, because a
publication is bound to one account and the withdrawal paths were asking whether
ANY account was configured. With two registered and the bound one removed the wide
answer stayed true, so a withdrawal was attempted against an account that no longer
existed, and the resulting failure was classified as a rejection a retry could fix.
The artifact could then be neither withdrawn nor deleted: both paths advised
retrying something permanent. The paths acting on an existing publication now ask
about that publication's own account. Providers binding nothing per publication
inherit the destination-wide answer, so only the drive implements it.

This enlarges what counts as unreachable, which is the population the delete path's
escape hatch applies to -- a removed account now takes the hatch instead of
refusing. That is the correct reading of a permanently absent account under either
resolution of the open question about whether the hatch should exist at all, but it
does mean the decision covers this case too.

The re-probe's AWS calls run in a worker thread. Every call in that body is a
blocking CLI subprocess, and the override ran them straight on the coroutine while
every sibling method wraps the same work in a thread, so one re-probe froze the
single gateway event loop across several serial subprocesses -- stalling every
task, the liveness heartbeat included, until the watchdog fired. The repo's
no-blocking-call ratchet cannot see it, because the subprocess is four frames down
behind the drive lookup, so the test asserts the property directly by recording
which thread the AWS layer is entered on.

A time-boxed exposure is no longer promised for a link that cannot expire. The
chosen expiry lives in one piece of state shared by every destination row, and a
row that cannot expire only HID the control -- leaving the choice standing
underneath it, so picking 72 hours on a row that can expire and then switching to
one that cannot left the acknowledgment promising a window that would never close.
The value now derives from the same predicate that hides the control, rather than
being reset when the selection changes, so the two cannot disagree: no missed reset
or render ordering can bring the promise back. Asserted on the derivation itself,
because the control is a Radix trigger rather than a native select and a
render-driven test of this passes whether the bug is there or not.

An artifact whose bytes are not in its text content is refused rather than
published empty. A kind="image" artifact keeps its raster beside the artifact and
carries no text body, and the publish pipeline is str-typed end to end: the kind
map has no entry for it, the fallback types it text/plain, and the destination
received a ZERO-BYTE object which was then recorded as a successful publish and
handed back as a working link. Nothing downstream could tell that from a real
publish. Serving images is not the obstacle -- the drive is a blob store and
declares every kind native -- so real support is a bytes path through the render
and tempfile steps, which is a larger change than this refusal.

The notice re-probe keeps its documented promise when no provider is registered.
It states that an unavailable provider leaves the stored notice untouched, but
provider resolution ran before that check and raised, which the handler turned into
a 503 on a read-only reconcile the caller can do nothing about. Resolution failing
is the strongest form of unavailable, so it now takes the same path.

A provider's notice text is scrubbed where it is first recorded. Every other
provider-derived string that reaches the dashboard passes through a redaction step
on the way -- the handlers scrub provider exception text before it reaches either
the UI or the audit log -- but a notice is persisted into the publication and
serialized straight out, so it reached neither. The re-probe path added later did
redact, and its comment claimed parity with the module's other provider-string
sinks; there were none, and the publish path where a notice is FIRST recorded was
the gap. For this drive it changes nothing, because the text is this module's own,
but notice is a seam field any provider fills, so the guard belongs at the sink
rather than at the one implementation that currently happens to be trustworthy.

The concurrent-creation path reasserts the read policy like the other reuse paths.
That policy pins the distribution ARN, so it cannot be installed until the
distribution exists -- and since the distribution is born carrying the drive's tag,
it cannot be installed until the drive is already discoverable. A first publish that
lost PutBucketPolicy to a denial or a throttle therefore leaves a drive that the
in-lock recheck finds complete, and returning there without re-installing handed
back a link to a bucket the delivery network cannot read: 403 for the life of the
account, reported as a successful publish. The two other reuse paths already
re-installed; this was the one that assumed the call had succeeded.

The consent copy no longer promises the exposure ends at delete. It said the link
stays public until you delete the artifact, which is false exactly when the
destination cannot be reached: that delete proceeds locally and leaves the copy
served. It now says the exposure ends when the published copy is withdrawn and names
deleting the artifact as what requests that withdrawal -- true whichever way the
open question about the unreachable-destination path is settled, so the copy stops
depending on that answer.

The racing-creation branch fails closed for the caller that hands out a link. Its
read-policy guard was wired to require_serving, but a publish resolves the drive
with that flag false -- it uploads before it checks the rollout -- while still being
the one path that returns a URL. The helper's own contract says the question is
deliberately not require_serving for exactly that reason, so the branch warned
instead of raising, uploaded, and handed back a link to a bucket the delivery
network cannot read. link_promised now threads from the caller through to it, and a
private publish still proceeds, because refusing there would turn an optional
permission into one every withdrawal requires.

A withdrawal that outlives its artifact no longer deletes the replacement. The
destination call is a network round trip and the local delete removes by slug, so a
delete and recreate landing in that window left the delayed request removing an
artifact the user never asked to remove and that nothing restores. The ordering note
argued a save in that window is included in the delete that was asked for, which
holds for a save to the same artifact; a recreate is a different artifact wearing
the same name. The generation is re-read after the call and a mismatch aborts.

A push no longer clears the serving notice. It writes bytes to the object store,
while the notice describes whether the delivery network has finished rolling out or
is switched off -- independent conditions, since puts succeed normally against a
distribution that is still deploying. Treating a successful upload as evidence about
the network hid a true warning while the link still did not resolve. Two tests
asserted the old behaviour and one stated the premise outright; both now pin that
only a re-probe, which asks the destination, may clear it.

The delete path's generation re-read runs off the event loop. Reading an artifact
returns it WITH content, so on a large one it is a multi-megabyte synchronous read,
and the handler is async -- inline it stalls the gateway for every other task. It
now goes through the module's own off-loop helper, which propagates the exception
unchanged so the surrounding guard is untouched. The capture read at the top of the
same handler has the same shape and is left alone: it predates this change and is
someone else's defect to weigh.

The drive stops advertising a kind the seam will refuse. It answered NATIVE for
everything, which is true of the destination -- a blob store can hold any bytes --
and false about what a publish does, because the render path is text-typed and an
image keeps its raster outside the artifact's content. The share-panel picker only
offers a provider whose answer is not unsupported, so an image artifact was offered
a publish that then refused it. The set of kinds the seam cannot carry now lives on
the provider interface both sides already import, so the declaration and the refusal
read one source and cannot drift.

All three publish-outcome readers carry the notice discriminator. Two of them copied
the machine code alongside the human notice; the app-provider confirm branch copied
only the notice, and the copy selector keys off the code -- so a disabled-distribution
notice rendered the generic warning instead of the remedy that tells the user how to
re-enable it, which is the one case whose whole purpose is that instruction. The pin
is a source-level one rather than a rendered-flow assertion, because the defect is a
field missing at one of several sites: a test that drives a single path would have
stayed green on the other two, which is how this survived being introduced.

The serving re-check resolves the drive read-only. It went through the resolver that
re-asserts the bucket policy whenever the distribution carries an ARN, which is a
policy WRITE -- so a control whose whole purpose is to look again could mutate the
account, and it is reachable from a state where publishing was refused, letting a
caller who could not publish drive that write through the notice path. It now takes
the read-only lookup. The resolver's own comment claims the only caller asserting the
network is the make-public path; that claim was true until this re-check started
sharing it, and taking the read-only lookup keeps it true rather than widening it.

A docstring also still listed push among the operations that reset the stored notice.
An earlier change in this branch made the push path preserve both notice fields, so
that list described behaviour the code no longer has.

The drive's headers policy now denies framing. It set a content policy, content-type
options, strict transport and a referrer policy, and said nothing about who may FRAME a
published document -- so any third-party origin could frame a public artifact and drive
clickjacking against whatever it renders.

Both mechanisms are set, and the placement is the point. The directive rides in the
content-policy constant the reuse path already VERIFIES: a same-named policy whose
content policy does not match exactly fails closed, so framing protection cannot be
skipped on an account that already holds a policy under that name. Expressed only as
the legacy header it would have been reused unverified, which is the same fail-open the
content-policy comparison exists to prevent. The legacy header is set as well, for
browsers that honour only it, and both deny rather than allow same-origin framing: one
drive serves every artifact from a single domain, so "same origin" here is another
published document rather than a trusted host -- exactly the cross-document relationship
the opaque origin exists to deny.

Both overwrite branches preserve the serving notice too. An overwrite force-pushes
local bytes over the remote's current version, which is an object-store operation; the
notice describes the delivery network -- still rolling out, or switched off -- and the
overwrite probes neither. Blanking it there asserted that the link resolves on the
strength of an operation that never asked, the same false premise the push path had.
The error field still clears, because that one does describe the operation that just
ran. Only the re-check, which asks the destination, may clear the notice.

The withdrawal-handle contract, in one rule: an artifact's local delete proceeds only
when there is nothing left to withdraw, or the destination confirmed the withdrawal.
Everything else refuses, and says why. Refusing a delete is recoverable -- retry it, or
unpublish and accept the exposure in the open. A world-readable copy whose only handle
was just erased is not.

A destination this process cannot reach no longer counts as gone. Unreachable describes
our access, not the object, which may still be served to everyone; the delete used to
proceed there and drop the record, which is precisely how a copy ends up with no handle.
The one outcome that still permits the delete is a CONFIRMED absent destination, and the
provider signals that with a dedicated exception type rather than a phrase in an error
message -- a throttled or unauthorized reply carries the same words while the object is
still live, so reading the text would hand back "gone" for a copy that is not. This
widens the set of deletes that refuse, which is the intended trade rather than a
side effect.

First publishes of one artifact are serialized in the engine. Two concurrent ones each
minted their own destination id, uploaded a distinct public object, and the record
written second replaced the first -- one copy served with its handle recorded nowhere.
The engine holds the identity that makes this fixable: a provider is handed a rendered
file and no artifact id, and mints the id itself, so its own per-id locking cannot see
the collision. Serializing per artifact one layer up fixes every provider at once and
leaves the provider interface untouched. The lock spans the whole operation because the
choice between first-publish and re-publish is itself what was racing.

The folder cascade withdraws what it destroys. Cascading a folder deletes artifacts
through a path that knows nothing about publications, so it erased records while the
copies stayed served -- the same defect through a different door. It now withdraws every
published copy in the subtree first and destroys nothing until they are all withdrawn;
the first copy that will not come down refuses the whole cascade and leaves the folder,
the artifacts and their handles in place.

Deliberately not included: a persisted ledger of orphaned copies. That is the shared
after-the-fact remedy for all three of these, and it is a feature with its own storage
and lifecycle rather than a patch belonging to this change.

The cascade's withdrawal preflight is not on its own enough to let it destroy, because
nothing holds a lock across the preflight and the destruction that follows -- the folder
tree and the artifact store have independent locks and taking both invites an ordering
deadlock. An artifact filed into the subtree after the preflight enumerated it reaches
the destruction still holding a publication nobody withdrew, and destroying it erases
the only handle able to take that copy down. A comment on that path documented the race
as accepted, which it was while the consequence was a stale folder id readers already
degrade to Unfiled; making the cascade withdraw is what turned the consequence into
destruction, so the comment is now corrected rather than left to mislead.

The invariant is therefore enforced where the destruction happens instead of by trusting
the earlier scan: the cascade refuses to destroy any artifact that still has a
publication, keeps it, and reports it in the summary and to the user. The preflight
clears the record of every copy it withdrew, so a still-published artifact at that point
is exactly a late arrival. The folder-tree change is already committed and cannot be
rolled back there, so the artifact survives unfiled -- recoverable, which an orphaned
public copy is not. The narrower window inside that scan, a publish landing between the
scan and that artifact's own delete, is disclosed rather than closed: closing it means
moving the invariant into the store's own delete, which every caller would inherit.

The cascade asks the delete itself to refuse rather than checking first. A check in the
cascade loop reads a snapshot, so a publish landing between the scan and that artifact's
own delete was still destroyed -- the same defect one window narrower. `ArtifactStore.
delete` now takes an opt-in `refuse_if_published` and re-reads the record inside the
same lock as the removal, which is what makes the answer hold. The flag defaults to
False so the single-artifact path is untouched: it is answered at its handler, which
attempts the withdrawal and refuses on the outcome. A refused artifact is reported, not
skipped silently, because a partly-refused cascade must not read as a completed one.

Unpublishing a confirmed-gone destination now completes instead of looping forever.
`reachable_for` resolves the PROFILE, not the drive, so a drive deleted under a
still-registered profile passes the reachability guard and then raises inside the
provider -- where a broad handler reported it as a removal the destination "did not
confirm", advising a retry that can never succeed. Combined with the delete path
refusing on that same destination, the artifact could be neither unpublished nor
deleted. A typed DriveNotFound now releases the record, because there is nothing left to
withdraw; the type is what carries it, never the message text, for the same reason as on
the delete path.

The unreachable-destination refusal no longer offers deleting the artifact as the way
out. It told the user "if it is gone, delete the artifact to drop the record along with
it", which was true while that delete dropped the record and false once it started
refusing on an unwithdrawn copy. Naming a remedy that is guaranteed to refuse is worse
than naming none, so it now points at the one action that can work.

A destination lookup that finds nothing is no longer reported as confirmed absence. The
drive resolves itself by TAG, so a miss says the LOOKUP failed -- a tag removed by hand
or a transient answer from the tagging API both leave the bucket and distribution
serving. Reporting that as proof let the withdrawal paths release the local record, the
only handle able to take that copy down, on evidence no stronger than the substring match
the typed signal was introduced to replace. The type was right; its single raise site was
asserting something it could not know.

The lookup miss is now a plain refusal, and no site raises the confirmed-absence type at
all -- the two branches that read it are unreachable and say so at the definition. Proving
absence needs a positive probe that asks the destination about the resource the
publication names, rather than asking a directory whether it can still find it. Until
that exists, nothing releases a handle except a confirmed withdrawal.

The cost is stated rather than hidden: a drive genuinely deleted under a still-registered
profile can no longer have its record cleared, because reachability resolves the profile
and not the drive. That is the deliberate side of the trade. A record that will not clear
is recoverable by its owner; a world-readable copy whose handle was erased is not.

The drive registers under its own key rather than the seam's default one, which is
what makes it opt-in. An unnamed publish resolves the default key, that key stays
unregistered, and such a publish gets the same 503 as an edition with no provider at
all -- while the picker still lists the destination and a caller can name it. The
feature ships; it just does not capture the path nobody chose.

Holding the default back is a scope decision, not a gap in the transfer. Five review
rounds converged on one question rather than five: what proves a publication exists,
or no longer exists, and which paths may act on that. Answering it needs agreement
across the publish engine, the artifact store and the folder store -- three lock
domains that do not nest, whose cross-lock coordination this change set already
declined once for ordering deadlock -- plus a positive absence probe, because
resolving the destination by tag can only report that a lookup failed and never that
a resource is gone. That contract is #8148.

Two windows depend on it and are left open rather than patched a sixth time: a first
publish can race a concurrent delete around the engine's per-artifact publish lock,
which the delete paths do not take, and an unpublish against a drive deleted under a
still-registered profile refuses instead of releasing the record. Opt-in is what makes
shipping them acceptable: each is now reachable only by someone who chose this
destination deliberately, rather than by every user on the default path. Moving the key
to the default is the last step of #8148, not a step of this change.

An earlier round declined this same reduction on the grounds that it removed the
change's purpose, which was right on the information available then. What changed is
knowing that shipping it as the default requires the cross-store contract, so it is no
longer the same trade.

Two spec claims are corrected to match the registration. The platform-context walkthrough
still said the default registry registers the drive "under DEFAULT_PROVIDER, so publishing
is available out of the box", which the opt-in key makes false in both halves. And the
artifacts endpoint table never listed the notice re-check endpoint this change adds, so the
structural map was missing a route that exists -- the row now also records that the
re-check resolves through the destination's READ path, which is the property that keeps a
re-check from provisioning anything.

An unreadable delivery-network flag no longer reads as "checked, and the condition
cleared". The notice derivation asks CloudFront whether the distribution is enabled, and
a failed read of that one flag was reported as "enabled" -- so a throttled or
unauthorized answer walked past the disabled branch and reached the final empty pair,
which the re-probe takes as permission to clear the stored notice. That dropped a warning
that may still be true, and the method's own contract two functions away says every
failure must instead report that nothing could be established.

The flag now has three answers -- enabled, disabled, and could-not-read -- because its
two callers need opposite handling of the third. The re-probe reports could-not-read as
"nothing established" and leaves the record untouched. The publish path keeps treating it
as no objection, deliberately: the shipped IAM tier does not grant
cloudfront:GetDistributionConfig, so refusing there would turn a permissions gap into a
refused publish, which an existing test pins. Same value, opposite handling, because the
cost of guessing is reversed between the two.

Unpublish's contract is also corrected. It still described itself as the deliberate way
out -- the owner accepting that the published copy may remain -- which stopped being true
once it began keeping the record unless a removal or a confirmed absence says otherwise.
It now states what it does, and that no exit currently exists: the confirmed-absence
branch is unreachable because nothing raises that type, so a publication whose
destination stopped answering cannot be cleared from here and deleting the artifact does
not clear it either. Giving an owner a real way out needs the absence probe tracked with
the publication-existence contract, not a looser rule in this function.

The claim that an owner has a way out is removed everywhere it was still stated. Last
round corrected it in unpublish's own docstring; a grep for the claim rather than the
symbol found it standing in four more places -- the delete-outcome enum's rule paragraph,
the delete handler's comment explaining why it refuses, and two passages in the artifacts
spec, one of which named DELETING the artifact as the exit and the other unpublish. Each
was written when it was true, and each survived the change that falsified it because
nothing links them.

All five now say the same thing: a refused delete is recoverable in the sense that it can
be retried once the destination answers again, and in no other sense. There is currently
no action that accepts the exposure and clears the record, and giving an owner one needs
the positive absence probe tracked with the publication-existence contract.

A publication's handle pins a profile NAME, and four places claimed it pins the account.
One of them was make_external_id's own one-line docstring, which said it binds "the
resolved account" while the line under it interpolated the profile name. A profile name is
a local alias the owner can repoint at another account, so the claim was not a wording
slip: it described a guarantee that would have made a repointed profile harmless, and
instead a repoint means a withdrawal resolves the name to the new account, removes
nothing, reports success, and clears the record -- leaving the original copy public with
its handle erased.

All four now say what is actually pinned, and the resolver that every mutation path routes
through says both what it answers and what it does not. Binding the account id is the fix
and is deliberately not improvised here: the separator is partitioned on its FIRST
occurrence so a profile name may itself contain it, which makes appending a field
ambiguous, and six parse sites read the current shape. It is recorded with the
publication-existence contract at data-loss severity so the sequencing is a maintainer
decision rather than a sixth unilateral change to what a publication's identity means.

Refuse a single-artifact delete when a republish lands after the withdrawal.

The existing `created_at` guard compares artifact GENERATIONS, which catches a
delete-and-recreate under the same slug but not a re-publish: that is the same artifact, so
its `created_at` is unchanged and the guard passes. The new publication is a live public
copy whose record is the only handle able to take it down, and removing the artifact
dropped it.

The store already had the machinery -- `delete(refuse_if_published=True)` re-reads the
record inside the same lock that removes the artifact -- but the single-artifact path could
not use it, because on that path `delete_for_artifact` never cleared the record, so it was
still present on the ordinary successful path and the guard would have refused every
published delete. So the handler now clears the withdrawn record explicitly, which is what
turns "a record is still here" into a usable signal, and then deletes under the flag. Both
delete paths are now the same shape; the folder cascade already worked this way.

Mutation-verified in both directions: dropping the flag reddens only the new test, and
dropping the clear reddens three ordinary published-delete tests with 409 != 200, which is
the concrete reason the flag cannot simply default to True. The flag's docstring claimed it
existed for BULK callers because the single-artifact case was answered at the handler; that
rationale was falsified by this change and now states the real precondition.

Stop pointing users at unpublish as a way out of an unreachable destination.

Both refusal messages ended by offering "or unpublish first to accept that the published
copy may remain". That operation refuses on an unreachable destination for exactly the same
reason the delete just did -- it keeps the record unless a removal or a confirmed absence
releases it -- so the advice sent the owner to a second dead end and implied an escape
hatch that does not exist. `DeleteWithdrawal`'s docstring already said so correctly; the
strings the user actually reads did not.

Three sites, found by grepping the claim's phrasings rather than a symbol: the single-delete
UNREACHABLE message, the folder-cascade equivalent, and a comment in the cascade's keep
branch that described the owner "withdrawing or unpublishing" its way out. A fourth hit was
left alone deliberately -- the provider-switch comment requires an unpublish on a REACHABLE
destination, where it works.

Verify the sandbox headers policy is still ATTACHED when reusing an existing drive.

`_sandbox_headers_policy` already refuses to reuse a same-named policy whose CSP is not
exactly the sandbox one, which defends the moment a drive is CREATED. It cannot defend a
drive that already exists: the policy object can be perfect and simply not be the one
attached any more. Detach it, or point the default cache behaviour at a different policy,
and every artifact on this pooled domain is back to sharing one real browser origin -- the
single cost of pooling this module's own docstring says cannot be argued away. The reuse
branch re-asserted the BUCKET policy two lines earlier and never looked at this one.

A public publish now verifies the attachment and refuses on a CONFIRMED mismatch, naming
the policy to re-attach. An unreadable distribution config does NOT refuse: that third
state is the same one the enabled flag uses, because an install whose IAM policy was
narrowed would otherwise have a permissions gap reported to it as tampering. Withdrawal and
take-private are deliberately not gated on it -- refusing to REMOVE content because a
header is missing would strand the copy that header was containing.

Gated on `link_promised`, not `require_serving`: the sole production caller passes
`require_serving=False`, so gating there would have been dead code. Mutation-verified both
ways -- removing the check reddens only the two tamper tests, and failing closed on an
unreadable read reddens the new tolerance test AND the pre-existing
`test_unreadable_distribution_config_does_not_block_publishing`, which is what proves the
leniency was already a pinned contract rather than a new invention.

Also corrects `_distribution_enabled`'s docstring, which gave a false reason for correct
behaviour: it claimed the shipped IAM tier does not grant `cloudfront:GetDistributionConfig`.
The documented drive tier DOES grant it; the leniency is for installs whose policy was
narrowed or predates that tier.

Verify the sandbox attachment on EVERY transition into public serving, not just publish.

The previous commit put that check on the first-publish path only. A private -> public flip
reaches the drive through `_require_drive`, a DIFFERENT resolver, so it started serving bytes
on the pooled domain without the opaque-origin guarantee. Both review lanes found this
independently and they were right: gating the guarantee on how an artifact happened to
become public is not a guarantee.

The gate differs per method for a reason worth stating. In `_require_drive`, `require_serving`
IS the make-public signal -- its own comment already said so for the bucket policy ("the only
caller that asserts the network is make-public") -- so the check hangs off it there. In
`_ensure_drive` the sole caller passes `require_serving=False`, so the same choice would have
been dead code, and it hangs off `link_promised` instead.

Three paths stay deliberately ungated, each for the same underlying rule -- never refuse an
operation that reduces exposure. Withdrawal and public -> private are removals. `push_version`
replaces bytes in place and hands out no new link, so if the header were already missing that
artifact was already served without isolation before the push: refusing un-exposes nothing
and only strands its owner on the old bytes. Each of the three now has a test pinning that it
is NOT blocked, so a later tightening cannot quietly turn one into a refusal.

Keep the provider off the gateway boot path, and stop mislabelling a re-check failure.

Two findings the rebase surfaced, both genuinely in this change.

`register_publish_providers` runs inside platform bootstrap, before the socket is bound,
and it imported the provider module eagerly -- ~0.5s of cumulative import, because the
module reaches the deploy engine's profile registry, the artifact store and the validation
stack. That is work added to every install's time-to-ready for a destination most of them
never select, now that the drive is opt-in, and `no-new-work-on-gateway-boot-path` is a
written rule here. The registry was already factory-based and instantiates lazily, so only
the import needed moving: bootstrap now registers a factory that imports on first
selection. The key is spelled in `defaults.py` so bootstrap need not import the module to
learn it, and a test pins that the copy still equals PERSONAL_DRIVE_PROVIDER.

The notice re-check wrote its failures to `setSaveError`, the editor's save channel, whose
surrounding copy tells the user their edit did not persist. It did persist -- nothing was
being saved -- so the message sent them to re-save untouched content. The re-check now has
its own state, rendered beside the button that triggered it.

The boot test took three attempts and each failure is worth recording. It first passed WITH
the eager import restored, because `kiro_crew.publish` stays imported with `personal_drive`
bound as an attribute, so `from ... import personal_drive` resolves the attribute and never
enters the import system; the parent's attribute has to be deleted too. It then broke
`test_public_edition_registry_wires_the_drive`, because re-importing produced a second
`PersonalDriveProvider` class and the older reference failed `isinstance` against it; the
teardown now restores the SAME module object. Mutation-verified in both directions.

Declare the factory's return type instead of silencing the linter that caught it.

The previous commit annotated the lazy factory as `-> "PublishProvider"` and suppressed
the resulting flake8 F821 with a noqa, because the name is deliberately not imported at
runtime. That silenced the warning without making the annotation true: the name really was
undefined in this module, and mypy said so -- `Backend Lint & Type Check` failed on exactly
one error, `defaults.py:112: Name "PublishProvider" is not defined`.

The name now comes from the existing `TYPE_CHECKING` block, which costs nothing at runtime
and makes the annotation resolve for real, so the noqa is gone with it. A suppression that
hides a true report is worse than no annotation.

Pin the AWS ACCOUNT into a publication's handle, not just the profile name.

A profile name is a local alias. Repointing it at another account and then deleting an
artifact resolved the name to the NEW account, deleted an absent key there, reported
success, and cleared the record -- leaving the original copy public with the only handle
able to take it down erased. No concurrency and no failure were needed, just a config edit
and an ordinary delete, which is why this is fixed here rather than deferred with the
timing-dependent windows.

The handle becomes `<key>~<account>~<profile>`. The account goes in the MIDDLE, and the
middle field is read as an account only when it is twelve digits, because a profile name may
itself contain the separator. Appending instead would have been ambiguous, and counting
separators would misread a pre-binding `key~we~ird` handle as account `we` -- then compare
`we` against a live account id, mismatch forever, and refuse every operation on it. There is
one parser, and `split_external_id` keeps its two-field shape so its eight callers are
unchanged.

`_profile_for` is the one function every mutation path resolves credentials through, so the
verification lives there rather than at each caller -- the same reasoning that put the
sandbox-header check on the shared resolver. A mismatch refuses. So does an account that
cannot be read: `sts:GetCallerIdentity` is gated by no IAM policy, so a failure means
credentials did not resolve rather than a tier being narrow, and acting on an account we
cannot identify risks removing nothing while discarding the record. The account is read
before the handle is minted, so a publish that cannot confirm it fails before uploading.

A handle with no account predates the binding and keeps the older behaviour instead of being
refused; refusing would strand every publication made before this change for no gain.

Mutation-verified both ways, and the second one caught a bad test: disabling the mismatch
check reddens the repointed-profile test, while removing the twelve-digit guard reddened
NOTHING until the parser test covered `abc~we~ird` -- a two-field handle has no second
separator, so the guard never fires on the case I had asserted.

Route the re-check error through ErrorNotice, and record the new endpoints in the feature map.

The re-check failure rendered as a hand-written red span. `errors-use-error-notice` is a
written rule here, and unlike `PublishHub` -- which has seven such sites and no `ErrorNotice`
usage at all, on main as well as here -- `ArtifactDetailPage` already uses `ErrorNotice`
twice and had zero raw red spans before this change. Mine was the only one, so this is the
asymmetry the rule exists to catch rather than a component-wide gap. It carries the required
`{/* No hand-off: ... */}` comment: this panel sits beside an editable buffer that `askAgent`
would destroy by navigating away, and a failed re-check is retryable in place.

The feature map's Publishing row now lists the publish, unpublish, refresh, reprobe-notice
and sharing endpoints, and names both components that drive them. The row was owed once the
rebase brought `docs/feature-map/README.md` into the tree. Each endpoint was checked against
the router rather than transcribed -- a first draft cited a `publish/core` route that does
not exist.
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

/ai-review override gpt 362153a: neither is a false positive -- the isolation check fails open by design because GetDistributionConfig must be granted explicitly, so refusing would turn a narrowed policy into a permanent publish outage reported as tampering (pinned by test_unreadable_distribution_config_does_not_block_publishing), and the upload-before-persist window is a real open defect tracked on #8148, accepted as residual risk for a destination that is opt-in and not the default.

Recorded deliberately rather than to clear a lane: this gate is not a required status check on this repository, so the override changes no merge condition. It is here so the verdict carries its reasoning instead of standing unanswered.

The second finding is genuinely open. It needs a per-slug lifecycle lock spanning load, withdrawal and local delete, plus an expected-generation delete in the store -- the racing read happens in the delete handler above the publish seam, so wrapping the withdrawal alone would look like a guard while closing nothing. That is the contract change tracked on #8148, not something this PR quietly resolved.

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Human judgment recorded

@chenmingwei23 marked the gpt AI finding as false positive, not applicable, or explicitly accepted for 362153a0ece58b1dcaf85d2e01cb0150b641de1a.

neither is a false positive -- the isolation check fails open by design because GetDistributionConfig must be granted explicitly, so refusing would turn a narrowed policy into a permanent publish outage reported as tampering (pinned by test_unreadable_distribution_config_does_not_block_publishing), and the upload-before-persist window is a real open defect tracked on #8148, accepted as residual risk for a destination that is opt-in and not the default.

This decision applies only to this commit. A new push requires a new judgment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants