@@ -632,3 +632,222 @@ describe('#6563 — rollbackToPackageCommit inherits the per-item intent', () =>
632632 expect ( fields ) . not . toContain ( 'due_date' ) ;
633633 } ) ;
634634} ) ;
635+
636+ /**
637+ * #6620 — the OTHER limb of the same loop: SOFT-REMOVE states its intent too.
638+ *
639+ * `revertCommit` has two limbs, and #6563 (above) only fixed the restore one.
640+ * The limb that undoes an artifact the commit CREATED stated its intent as a
641+ * CONSTANT — `intent: 'override-artifact'`, written into the `repo.delete(...)`
642+ * call — and `SysMetadataRepository.delete` opens with the same
643+ * `assertAllowed(ref.type, opts.intent)` gate `put` uses. So `object`, which is
644+ * not `allowOrgOverride`, was refused on the delete path exactly as it had been
645+ * on the restore path, and a commit that CREATED an object could not be
646+ * reverted either.
647+ *
648+ * That is the FIRST-BUILD undo — publish a brand-new app, then undo it — which
649+ * is the flow Studio and AI authoring produce most. Every object the commit
650+ * created stayed behind, `success` came back `false` with a populated
651+ * `failed[]`, and the package was left half-reverted: its overlay-allowed items
652+ * removed, its objects not.
653+ *
654+ * The two causes are different even though the symptom rhymes: #6563 was an
655+ * UNSTATED intent falling through to the repository's `?? 'override-artifact'`
656+ * default, this one is a literal the caller wrote down. The fix is the same
657+ * family shape — derive it per item from `isArtifactBacked`, the way the
658+ * sibling delete caller `deleteMetaItem` and the sibling revert caller
659+ * `rollbackMetaItem` both already do — so all three delete/revert callers now
660+ * agree, and the repository's gate is untouched.
661+ */
662+
663+ /** The commit item shape for an artifact this commit CREATED (ADR-0067). */
664+ const createdItem = ( name : string ) => ( {
665+ type : 'object' , name, existedBefore : false , prevVersion : null ,
666+ } ) ;
667+
668+ /** The first-build shape: authored ONCE, never edited — nothing to restore to. */
669+ async function seedCreatedObject ( protocol : any , name : string , packageId ?: string ) {
670+ await protocol . saveMetaItem ( {
671+ type : 'object' , name, ...( packageId ? { packageId } : { } ) , item : invoiceBody ( name ) ,
672+ } ) ;
673+ }
674+
675+ const storedRows = ( rows : Map < string , any > , name : string ) =>
676+ Array . from ( rows . values ( ) ) . filter ( ( r ) => r . name === name ) ;
677+
678+ describe ( '#6620 — revertCommit soft-removes a runtime-CREATED `object`' , ( ) => {
679+ it ( 'a package-bound created object reverts: revertedCount 1, failed [], row gone' , async ( ) => {
680+ const { protocol, rows, historyRows } = makeRealRepoHarness ( [ objectCommit ( {
681+ id : 'cmt_new' ,
682+ items : [ createdItem ( 'myapp_invoice' ) ] ,
683+ } ) ] ) ;
684+ await seedCreatedObject ( protocol , 'myapp_invoice' , APP_PKG ) ;
685+ expect ( storedRows ( rows , 'myapp_invoice' ) ) . toHaveLength ( 1 ) ;
686+
687+ const res = await protocol . revertCommit ( { commitId : 'cmt_new' } ) ;
688+
689+ // Pre-fix, verbatim (the issue's measurement): success false, revertedCount
690+ // 0, failedCount 1 carrying "[NOT_OVERRIDABLE] 'object' is not
691+ // allowOrgOverride in the registry.", and the row still standing.
692+ expect ( res . failed ) . toEqual ( [ ] ) ;
693+ expect ( res . success ) . toBe ( true ) ;
694+ expect ( res . revertedCount ) . toBe ( 1 ) ;
695+ expect ( res . reverted [ 0 ] ) . toMatchObject ( { type : 'object' , name : 'myapp_invoice' , action : 'removed' } ) ;
696+ expect ( storedRows ( rows , 'myapp_invoice' ) ) . toHaveLength ( 0 ) ;
697+ // Soft, not hard: ADR-0067 §5 keeps the removal recoverable, so the delete
698+ // is an append-only tombstone in history rather than a vanished lineage.
699+ const tombstone = historyRows . filter (
700+ ( h ) => h . name === 'myapp_invoice' && h . operation_type === 'delete' ,
701+ ) ;
702+ expect ( tombstone ) . toHaveLength ( 1 ) ;
703+ expect ( tombstone [ 0 ] . metadata ) . toBeNull ( ) ;
704+ } ) ;
705+
706+ it ( 'a package-LESS created object reverts identically — the binding was never the cause' , async ( ) => {
707+ const { protocol, rows } = makeRealRepoHarness ( [ objectCommit ( {
708+ id : 'cmt_new_global' ,
709+ package_id : null ,
710+ items : [ createdItem ( 'global_invoice' ) ] ,
711+ } ) ] ) ;
712+ await seedCreatedObject ( protocol , 'global_invoice' ) ;
713+
714+ const res = await protocol . revertCommit ( { commitId : 'cmt_new_global' } ) ;
715+
716+ expect ( res . failed ) . toEqual ( [ ] ) ;
717+ expect ( res . revertedCount ) . toBe ( 1 ) ;
718+ expect ( storedRows ( rows , 'global_invoice' ) ) . toHaveLength ( 0 ) ;
719+ } ) ;
720+
721+ /**
722+ * The refusal that must SURVIVE the fix — and the one case the constant got
723+ * right by accident, which is why its direction is INVERTED: it was green
724+ * before the change and is green after. It cannot go red by removing the fix,
725+ * because removing the fix refuses EVERYTHING. What it does go red on is the
726+ * wrong fix — hard-coding `'runtime-only'` in place of the old
727+ * `'override-artifact'` — which is the mistake a one-line "just make objects
728+ * work" edit would make, and which would let a revert tombstone an artifact a
729+ * code package genuinely ships.
730+ *
731+ * Staged the way a real deployment stages it (as in #6563's block): the
732+ * overlay row is authored while the name is runtime-only, and the artifact
733+ * arrives with the package that later claims it. `registerObject(body, pkg)`
734+ * with no `_provenance` is the shape `applyProtection` stamps as `'package'`,
735+ * which is what `getArtifactItem` reads and `isArtifactBacked` answers on.
736+ *
737+ * Envelope note (ADR-0112): `revertCommit` converts a per-item throw into a
738+ * `failed[]` record whose DECLARED shape is `{ type, name, error, code? }` —
739+ * no `status`. So `code` is asserted here together with the condition's own
740+ * first sentence, and the full `{ code, status }` pair belongs to the
741+ * throwing surface (`protocol-writepath-object-ownership.test.ts`), exactly
742+ * as #6563 split it.
743+ */
744+ it ( 'still REFUSES soft-removing an artifact-backed object: NOT_OVERRIDABLE, row kept' , async ( ) => {
745+ const { protocol, registry, rows } = makeRealRepoHarness ( [ objectCommit ( {
746+ id : 'cmt_new_artifact' ,
747+ items : [ createdItem ( 'myapp_invoice' ) ] ,
748+ } ) ] ) ;
749+ await seedCreatedObject ( protocol , 'myapp_invoice' , APP_PKG ) ;
750+ registry . registerObject ( invoiceBody ( 'myapp_invoice' ) as never , APP_PKG ) ;
751+
752+ const res = await protocol . revertCommit ( { commitId : 'cmt_new_artifact' } ) ;
753+
754+ expect ( res . revertedCount ) . toBe ( 0 ) ;
755+ expect ( res . failedCount ) . toBe ( 1 ) ;
756+ expect ( res . failed [ 0 ] ) . toMatchObject ( {
757+ type : 'object' ,
758+ name : 'myapp_invoice' ,
759+ code : 'NOT_OVERRIDABLE' ,
760+ } ) ;
761+ expect ( res . failed [ 0 ] . error ) . toContain (
762+ `[NOT_OVERRIDABLE] 'object' is not allowOrgOverride in the registry.` ,
763+ ) ;
764+ // Refused means refused: the artifact-backed row is still there.
765+ expect ( storedRows ( rows , 'myapp_invoice' ) ) . toHaveLength ( 1 ) ;
766+ } ) ;
767+
768+ /**
769+ * PER ITEM, not per call — the half a single-item fixture cannot see, on the
770+ * soft-remove limb this time. One commit, two created objects, opposite
771+ * verdicts: a loop that hoisted one intent for the batch (which is precisely
772+ * what the constant did) has to pick one and be wrong about the other.
773+ */
774+ it ( 'derives the intent PER ITEM: one created object removed, its artifact-backed neighbour refused' , async ( ) => {
775+ const { protocol, registry, rows } = makeRealRepoHarness ( [ objectCommit ( {
776+ id : 'cmt_new_mixed' ,
777+ items : [ createdItem ( 'myapp_invoice' ) , createdItem ( 'myapp_quote' ) ] ,
778+ } ) ] ) ;
779+ await seedCreatedObject ( protocol , 'myapp_invoice' , APP_PKG ) ;
780+ await seedCreatedObject ( protocol , 'myapp_quote' , APP_PKG ) ;
781+ // Only the quote is claimed by a code artifact.
782+ registry . registerObject ( invoiceBody ( 'myapp_quote' ) as never , APP_PKG ) ;
783+
784+ const res = await protocol . revertCommit ( { commitId : 'cmt_new_mixed' } ) ;
785+
786+ expect ( res . reverted ) . toEqual ( [
787+ { type : 'object' , name : 'myapp_invoice' , action : 'removed' } ,
788+ ] ) ;
789+ expect ( res . failed ) . toHaveLength ( 1 ) ;
790+ expect ( res . failed [ 0 ] ) . toMatchObject ( { name : 'myapp_quote' , code : 'NOT_OVERRIDABLE' } ) ;
791+ expect ( storedRows ( rows , 'myapp_invoice' ) ) . toHaveLength ( 0 ) ;
792+ expect ( storedRows ( rows , 'myapp_quote' ) ) . toHaveLength ( 1 ) ;
793+ } ) ;
794+
795+ /**
796+ * A commit that created BOTH an overlay-allowed item and an object is the
797+ * half-reverted package the issue describes: pre-fix the view came out and
798+ * the object stayed, so `success` was `false` and the package sat in a state
799+ * neither before nor after the commit.
800+ */
801+ it ( 'reverts a mixed-TYPE first build whole: the view and the object both come out' , async ( ) => {
802+ const { protocol, rows } = makeRealRepoHarness ( [ objectCommit ( {
803+ id : 'cmt_new_build' ,
804+ items : [
805+ createdItem ( 'myapp_invoice' ) ,
806+ { type : 'view' , name : 'myapp_case_grid' , existedBefore : false , prevVersion : null } ,
807+ ] ,
808+ } ) ] ) ;
809+ await seedCreatedObject ( protocol , 'myapp_invoice' , APP_PKG ) ;
810+ await protocol . saveMetaItem ( {
811+ type : 'view' , name : 'myapp_case_grid' , packageId : APP_PKG , item : gridBody ( 'Cases' ) ,
812+ } ) ;
813+
814+ const res = await protocol . revertCommit ( { commitId : 'cmt_new_build' } ) ;
815+
816+ expect ( res . failed ) . toEqual ( [ ] ) ;
817+ expect ( res . success ) . toBe ( true ) ;
818+ expect ( res . revertedCount ) . toBe ( 2 ) ;
819+ expect ( storedRows ( rows , 'myapp_invoice' ) ) . toHaveLength ( 0 ) ;
820+ expect ( storedRows ( rows , 'myapp_case_grid' ) ) . toHaveLength ( 0 ) ;
821+ } ) ;
822+ } ) ;
823+
824+ /**
825+ * #6620 — the inheritance, on the soft-remove limb. `rollbackToPackageCommit`
826+ * reverts through the SAME loop, so it carried the same constant.
827+ *
828+ * As in #6563's inheritance pin, the status cannot show the defect:
829+ * `revertCommit` turns a per-item refusal into `failed[]` instead of throwing,
830+ * so the rollback recorded the commit as reverted and answered `success: true`
831+ * while the created object was never removed. The line that goes red pre-fix is
832+ * the STORED ROW.
833+ */
834+ describe ( '#6620 — rollbackToPackageCommit inherits the per-item soft-remove intent' , ( ) => {
835+ it ( 'rolls a first build back through the loop — and the created row really went away' , async ( ) => {
836+ const { protocol, rows } = makeRealRepoHarness ( [
837+ objectCommit ( { id : 'cmt_base' , items : [ ] , created_at : '2026-08-08T00:00:01.000Z' } ) ,
838+ objectCommit ( {
839+ id : 'cmt_build' ,
840+ items : [ createdItem ( 'myapp_invoice' ) ] ,
841+ created_at : '2026-08-08T00:00:02.000Z' ,
842+ } ) ,
843+ ] ) ;
844+ await seedCreatedObject ( protocol , 'myapp_invoice' , APP_PKG ) ;
845+
846+ const res = await protocol . rollbackToPackageCommit ( { commitId : 'cmt_base' } ) ;
847+
848+ expect ( res . revertedCommits ) . toEqual ( [ 'cmt_build' ] ) ;
849+ expect ( res . failed ) . toEqual ( [ ] ) ;
850+ // `success: true` was ALREADY true pre-fix — this is the line that was not.
851+ expect ( storedRows ( rows , 'myapp_invoice' ) ) . toHaveLength ( 0 ) ;
852+ } ) ;
853+ } ) ;
0 commit comments