Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
104 commits
Select commit Hold shift + click to select a range
3ecc93d
Amendment 5: tables are the structure — state-dependent sections
CST-100 Jun 12, 2026
c2debe5
Issues R2: nonconformance, containment, and disposition
CST-100 Jun 12, 2026
04d5bbe
Amendment 5 follow-up: the window, always-on tables, bfcache re-sync
CST-100 Jun 12, 2026
8f4a3c1
Execution UI: the multiplayer document
CST-100 Jun 12, 2026
768ac9d
Merge remote-tracking branch 'origin/devel' into claude/elated-arding…
CST-100 Jun 12, 2026
4735824
Re-parent execution migration onto risk-module migration head
CST-100 Jun 12, 2026
1a31b7f
Quiet empties: column heads render only over data
CST-100 Jun 12, 2026
2eaca7f
Fix permanently-visible lightbox dimming the execution document
CST-100 Jun 12, 2026
7ef96b2
Work order semantics: CUT/IN WORK statuses, batch cut, WO-first list
CST-100 Jun 12, 2026
0561855
Cache-bust static assets with mtime versions
CST-100 Jun 12, 2026
5e82628
Amendment 1: register calibration — badge discipline, advisory scopin…
CST-100 Jun 12, 2026
d6427e8
Amendment 2: view-mode default, de-ceremonied disposition, two-way WO…
CST-100 Jun 12, 2026
bd00fbb
Merge remote-tracking branch 'origin/features/issues-exec-parts-r2' i…
CST-100 Jun 12, 2026
473e8eb
Amendment 2 follow-up: disposition form behind EDIT, visible disabled…
CST-100 Jun 12, 2026
a770cb6
Amendment 1 backend: presence = focus, claims deleted
CST-100 Jun 12, 2026
c5ac460
Amendment 1+1.1 frontend: docked bar, cursors, floating rails, glyph …
CST-100 Jun 12, 2026
4e19c55
Merge features/issues-exec-parts-r2: reconcile risk module with issue…
CST-100 Jun 12, 2026
739dbf9
Merge pull request #49 from amorphous-engineering/claude/nifty-turing…
CST-100 Jun 12, 2026
698bd60
Rewrite execution tests for the focus model
CST-100 Jun 12, 2026
aac9a0e
Risk pages: view-mode default, de-ceremonied acceptance, register cal…
CST-100 Jun 12, 2026
428cd4d
Amendment 6: section relevance — procurement declares expected emptiness
CST-100 Jun 12, 2026
f8f8df5
Merge features/issues-exec-parts-r2: reconcile the empty-line seam
CST-100 Jun 12, 2026
b4ad36a
Rails in boxes, dockbar above the footer, smaller scroll reserve
CST-100 Jun 12, 2026
ea92c68
Merge pull request #48 from amorphous-engineering/claude/competent-po…
CST-100 Jun 12, 2026
33ba966
Enter expands the focused step
CST-100 Jun 12, 2026
cb260dc
Step bodies are the control surface; docked bar opt-in and column-ali…
CST-100 Jun 12, 2026
345deaa
Fix adversarial-review findings (core flow + API routes)
CST-100 Jun 12, 2026
14deaa3
Merge features/issues-exec-parts-r2: reconcile execution document wit…
CST-100 Jun 12, 2026
b6f22b9
Merge features/issues-exec-parts-r2 (part procurement increment)
CST-100 Jun 12, 2026
fd51742
Remove dead ExecutionCollaboration class
CST-100 Jun 12, 2026
b47c32a
Fix post-reconciliation review findings (JS, MCP, rename stragglers)
CST-100 Jun 12, 2026
817a323
Fix templates-dimension review findings
CST-100 Jun 12, 2026
c4e581a
Merge pull request #52 from amorphous-engineering/claude/elated-ardin…
CST-100 Jun 12, 2026
f3feaf8
Merge origin/devel: XSS tojson fixes and PATCH null rejection
CST-100 Jul 5, 2026
ec35e03
Merge pull request #51 from amorphous-engineering/claude/risks-regist…
CST-100 Jul 5, 2026
7b8b232
Fix review findings: XSS attribute quoting, step-image 500, stale lin…
CST-100 Jul 5, 2026
a0f56ff
F11: HOLDING link targets live document (?op=N), not dissolved ops tab
CST-100 Jul 5, 2026
431e0bd
F2: WO close-out authoritative on the issue set, not per-row hold buc…
CST-100 Jul 5, 2026
01fff38
Fix TUI execution workflow for the R2 focus/document model
CST-100 Jul 5, 2026
0bf5721
F3: reconcile a containment issue's work order against its step anchors
CST-100 Jul 5, 2026
78422d4
F8: containment boundary is clearable + WO-validated
CST-100 Jul 5, 2026
e22801e
F5: SKIP respects the open-redline-rework gate
CST-100 Jul 5, 2026
cd02a46
F4: single server-computed gate drives COMPLETE/SKIP controls
CST-100 Jul 5, 2026
e76dae5
cleanup: one home for enum-unwrap, terminal set, and assignable users
CST-100 Jul 5, 2026
422bc83
Merge pull request #60 from amorphous-engineering/claude/r2-hold-mode…
CST-100 Jul 5, 2026
a58be83
Merge pull request #59 from amorphous-engineering/claude/r2-tui-execu…
CST-100 Jul 5, 2026
9663c17
Rehearsal feedback: dead ISSUE/ATTACH buttons, stale HELD BY, registe…
CST-100 Jul 6, 2026
3ef7b0d
Issue capture: /issues/new links a work order
CST-100 Jul 6, 2026
45e432f
Doctrine amendment: gated COMPLETE/SKIP render inert + reason, not ab…
CST-100 Jul 6, 2026
3cc13ae
StepNote model, migration with backfill, core append helper
CST-100 Jul 6, 2026
42f2731
De-ceremony the signature confirms: one sentence, CONFIRM/ABORT inline
CST-100 Jul 6, 2026
ba6ddd5
Web + TUI: append-only note trail on every step
CST-100 Jul 6, 2026
f80dc2d
Anomaly capture: raised step is a labeled fact, RESOLVE BY names its …
CST-100 Jul 6, 2026
8758076
Rail state register, parent-OP complete gate
CST-100 Jul 6, 2026
44fec97
Redline discoverability: + REDLINE surfaces in the step action rows
CST-100 Jul 6, 2026
bf5469c
Merge remote-tracking branch 'origin/features/issues-exec-parts-r2' i…
CST-100 Jul 6, 2026
27bb2cb
Merge pull request #61 from amorphous-engineering/claude/r2-issue-ux-…
CST-100 Jul 6, 2026
0cdb877
Notes log as its own block + tests (API, MCP, web, migration backfill)
CST-100 Jul 6, 2026
6503d84
Merge remote-tracking branch 'origin/features/issues-exec-parts-r2' i…
CST-100 Jul 6, 2026
9fe3211
Merge pull request #62 from amorphous-engineering/claude/r2-step-notes
CST-100 Jul 6, 2026
6054d7c
Cut modal: no target-entity ceremony; BOM/KITTING tabs only when rele…
CST-100 Jul 6, 2026
c39fac5
Fix tab-relevance test to match rendered markup
CST-100 Jul 6, 2026
af28450
Cut lands on the overview; CUT badge is muted, not a warning
CST-100 Jul 6, 2026
7ca9b63
Drop ProcedureInstance.target_entity — dangling after cut-modal simpl…
CST-100 Jul 6, 2026
7600b71
Merge pull request #63 from amorphous-engineering/claude/recursing-je…
CST-100 Jul 6, 2026
44f4f4b
Add Mojave Sphinx (HCR-5100) seed data assets
CST-100 Jul 6, 2026
ce9c822
Seed rewrite: Mojave Sphinx demo replaces Project Kestrel
CST-100 Jul 6, 2026
f8bfa22
Demo users skip the onboarding tour
CST-100 Jul 6, 2026
d4717b3
Update Kestrel references to Mojave Sphinx; ship seed data in the binary
CST-100 Jul 6, 2026
c16e3b8
Tests: Mojave Sphinx seed expectations and demo-state invariants
CST-100 Jul 6, 2026
920cd87
Merge pull request #64 from amorphous-engineering/claude/sphinx-demo-…
CST-100 Jul 6, 2026
3949d59
Kit/output tables render part numbers, not database ids
CST-100 Jul 6, 2026
de26136
Lists, detail rows, and titles stop rendering database ids
CST-100 Jul 6, 2026
2cd26e0
Execution version rides the name, not a column
CST-100 Jul 6, 2026
a97d118
Sphinx demo refinement: clean titles, component procedures, sequencin…
CST-100 Jul 6, 2026
e1d6914
Inventory traceability register: OPAL# page in family, kitting shows …
CST-100 Jul 6, 2026
ad9bfc8
Merge pull request #66 from amorphous-engineering/claude/inventory-tr…
CST-100 Jul 6, 2026
a9dd5b4
Merge pull request #67 from amorphous-engineering/claude/sphinx-seed-…
CST-100 Jul 6, 2026
495c11e
Merge pull request #65 from amorphous-engineering/claude/unruffled-sp…
CST-100 Jul 6, 2026
87cf6ad
Requirements PATCH: explicit null clears nullable fields (issue #30)
CST-100 Jul 6, 2026
d6d11a6
Merge branch 'features/issues-exec-parts-r2' of https://github.com/am…
CST-100 Jul 6, 2026
ef232e6
opal.spec: bundle fido2 package data
CST-100 Jul 6, 2026
79658cb
F1: children-gate blocker names display numbers, not global orders
CST-100 Jul 6, 2026
9df22b0
F5: SKIP of a parent OP row respects the children gate
CST-100 Jul 6, 2026
0125071
F11: dockbar renders inert COMPLETE + reason on OP rows
CST-100 Jul 6, 2026
cec7dff
F4: op-card HELD BY has one derivation — bound holds fold in server-side
CST-100 Jul 6, 2026
fe63bc5
S3: step kits no longer demand mid-procedure subassemblies
CST-100 Jul 6, 2026
29703c5
F7: output-only procedures keep the KITTING tab (FINALIZE reachable)
CST-100 Jul 6, 2026
7b8738a
PO receive UX: PN column, location autofill, no silent line drop
CST-100 Jul 6, 2026
b0c4909
S1: PO-0001 quantities derive from the stock plan — one derivation
CST-100 Jul 6, 2026
da2999b
F8: OPAL page renders Purchase.reference verbatim — no PO- re-prefix
CST-100 Jul 6, 2026
f4fff36
S2: serialized shelf stock covers the open kit demand
CST-100 Jul 6, 2026
5a6ba95
F9: boundary '—' option sends an explicit null instead of no-oping
CST-100 Jul 6, 2026
830beeb
S6: completed WOs consume their serialized kit components
CST-100 Jul 6, 2026
087151b
S4: demo freshness derives from database content, not file existence
CST-100 Jul 6, 2026
f102e4c
F2/F6/F12(TUI): actionable-leaf targeting, kit field names, WO-first …
CST-100 Jul 6, 2026
bfd892b
S5: opal seed refuses configured instances; --force for the deliberat…
CST-100 Jul 6, 2026
aca828b
S7: welcome copy stops enumerating seed contents
CST-100 Jul 6, 2026
0afed96
F3: MCP bind_issue_hold re-bind path commits the WO adoption
CST-100 Jul 6, 2026
3740a2d
S8: tier 1 reserved for flight-critical parts, not commodity COTS
CST-100 Jul 6, 2026
d6aa5af
F10: eager-load steps→notes→author on the instances LIST path
CST-100 Jul 6, 2026
9fe7616
F12: executions named by work order in search and TUI copy (search half)
CST-100 Jul 6, 2026
6c41a95
Merge pull request #69 from amorphous-engineering/claude/r2-sweep-fix…
CST-100 Jul 6, 2026
046191f
Merge pull request #70 from amorphous-engineering/claude/r2-sweep-fix…
CST-100 Jul 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ uv run pyinstaller opal.spec # Output: dist/opal

### Test infrastructure
- Fixtures in `tests/conftest.py`: in-memory SQLite engine, per-test rollback transactions
- `client` fixture provides `TestClient` with dependency overrides
- `auth_headers` fixture provides `{"X-User-Id": str(test_user.id)}`
- `client` fixture provides `TestClient` pre-authenticated as an admin service user (Bearer token)
- `auth_headers` fixture provides `{"Authorization": "Bearer <token>"}` for `test_user`; `web_client` adds the session cookie for web-page GETs

## Critical Rules

Expand All @@ -87,9 +87,13 @@ Dense, explicit, functional. Expose state and inner workings. Data tables over c

**One fact, one home.** Every other appearance is a live reference, never a copy. Test: if updating something requires touching two places, the design is wrong — delete one occurrence or derive it. PRs that violate this must argue against it by name.

**The empty-state rule.** An empty section is one line, never a box: `{Section} — none · [+ action]`, muted text, 0.5-border row (`ok.empty_line` macro). A section earns vertical space only when populated. Empty states are facts, not features; six boxes announcing nothingness is anti-density.
**The empty-state rule.** Emptiness is information only where content is expected; declared intent decides where it's expected, and data always renders. A part's `procurement` (make | buy | both) declares expectation: BOM expects content on make|both, SUPPLIERS and PO LINES on buy|both, everything else always. An irrelevant empty section is absent — not collapsed, absent — while any section with rows renders regardless of the declaration. An empty relevant section is ONE line — `LABEL — none · + ADD` (`ok.empty_line`) — never a header bar over an empty box, never narration ("No X defined" is banned).

**Voice rules.** Spec prose is rationale for the implementer, never interface copy. (1) No interface copy that explains the interface — absent features are not apologized for. (2) Consequence sentences live only in confirmation dialogs and errors; each such sentence has exactly one home. (3) Labels are nouns, values are facts — no clauses, no narration. (4) Nothing hides behind disclosure: meta fields render always, as dense mono readouts. (5) Width is an information budget — max-width the content or fill the viewport with columns of data, never one stretched sparse column. (6) Context pre-fill: the form never asks what the invoking context already knows. (7) Database ids never render in lists, headers, or titles.
**Structure and register are separate layers.** Structure — panels, header bars, column-headed tables, full-width grids — is the app's shared grammar and may not be deleted by a register pass. Register — row pitch, accent budget, voice, contrast ladder — is where density lives. Data renders in tables: detail rows (`ok.detail_row`) for facts, headed `data-table`s for collections; a register amendment tightens a table's pitch, it does not dissolve the table. The parts list and part page are the reference implementations.

**Voice rules.** Spec prose is rationale for the implementer, never interface copy. (1) No interface copy that explains the interface — absent features are not apologized for. (2) Consequence sentences live only in confirmation dialogs and errors; each such sentence has exactly one home. (3) Labels are nouns, values are facts — no clauses, no narration. (4) Nothing hides behind disclosure: meta fields render always, as detail-row tables; absence is a muted dash, never an omitted row. (5) Width is an information budget — max-width the content or fill the viewport with columns of data, never one stretched sparse column. (6) Context pre-fill: the form never asks what the invoking context already knows. (7) Database ids never render in lists, headers, or titles.

**Register rules.** (1) No emoji or pictographs — state words in state colors carry state. Arrows (→ ←), box-drawing rules (──), and geometric chevrons are typography and stay. (2) State badges only where the state is exceptional or actionable — a state badge on every row is information about nothing. (3) Accent color = identifier + action, never content. (4) Consequence renders where it lands, not where it's filed — a column earns its place by the question the page answers.

## Linting (Ruff)

Expand Down
1 change: 1 addition & 0 deletions OPAL_manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ A **Part** in OPAL represents a design-level component - the abstract definition
| **Description** | No | Detailed specifications |
| **Parent Part** | No | For assemblies - what this is part of |
| **Tracking Type** | Yes | BULK (counted by quantity) or SERIALIZED (each item tracked) |
| **Procurement** | Auto | MAKE / BUY / BOTH — defaults to BUY when an External PN is given, else MAKE; decides which part-page sections expect content |
| **Metadata** | No | Custom JSON fields for extra data |

4. Click **Create Part**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Execution document: step focus (cursor presence), step images, capture attribution, strict_sequence

Revision ID: 26297a8e0c76
Revises: 52973e130a6f
Create Date: 2026-06-12 12:00:00.000000

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '26297a8e0c76'
down_revision: Union[str, None] = '52973e130a6f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# Cursor presence — one row per (instance, user), upserted as the cursor
# moves. Ephemeral presence, not history: moving records no event.
op.create_table(
'step_focus',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('instance_id', sa.Integer(), nullable=False),
sa.Column('step_execution_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('focused_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['instance_id'], ['procedure_instance.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['step_execution_id'], ['step_execution.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('instance_id', 'user_id', name='uq_step_focus_user'),
)
op.create_index(op.f('ix_step_focus_instance_id'), 'step_focus', ['instance_id'], unique=False)
op.create_index(
op.f('ix_step_focus_step_execution_id'), 'step_focus', ['step_execution_id'], unique=False
)
op.create_index(op.f('ix_step_focus_user_id'), 'step_focus', ['user_id'], unique=False)

# Soft telemetry: first cursor focus per step; never rendered as state.
with op.batch_alter_table('step_execution', schema=None) as batch_op:
batch_op.add_column(sa.Column(
'first_focused_at', sa.DateTime(timezone=True), nullable=True,
comment='Soft telemetry: first cursor focus; never rendered as state',
))


# Authored step reference images — procedure content, snapshotted at publish.
op.create_table(
'step_image',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('step_id', sa.Integer(), nullable=False),
sa.Column('attachment_id', sa.Integer(), nullable=False),
sa.Column('caption', sa.String(length=255), nullable=True),
sa.Column('position', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['step_id'], ['procedure_step.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['attachment_id'], ['attachment.id'], ondelete='RESTRICT'),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_step_image_step_id'), 'step_image', ['step_id'], unique=False)
op.create_index(
op.f('ix_step_image_attachment_id'), 'step_image', ['attachment_id'], unique=False
)

# Execution-capture attribution + note on attachments.
with op.batch_alter_table('attachment', schema=None) as batch_op:
batch_op.add_column(sa.Column(
'note', sa.Text(), nullable=True,
comment="Capturer's note on an execution capture",
))
batch_op.add_column(sa.Column('uploaded_by_id', sa.Integer(), nullable=True))
batch_op.create_index(
batch_op.f('ix_attachment_uploaded_by_id'), ['uploaded_by_id'], unique=False
)
batch_op.create_foreign_key(
'fk_attachment_uploaded_by_id_user', 'user', ['uploaded_by_id'], ['id'],
ondelete='SET NULL',
)

# Opt-in in-order sub-step execution on an OP.
with op.batch_alter_table('procedure_step', schema=None) as batch_op:
batch_op.add_column(sa.Column(
'strict_sequence', sa.Boolean(), nullable=False, server_default=sa.false(),
comment='OP-level: sub-steps must start in order (N requires N-1 terminal)',
))


def downgrade() -> None:
with op.batch_alter_table('procedure_step', schema=None) as batch_op:
batch_op.drop_column('strict_sequence')

with op.batch_alter_table('attachment', schema=None) as batch_op:
batch_op.drop_constraint('fk_attachment_uploaded_by_id_user', type_='foreignkey')
batch_op.drop_index(batch_op.f('ix_attachment_uploaded_by_id'))
batch_op.drop_column('uploaded_by_id')
batch_op.drop_column('note')

op.drop_index(op.f('ix_step_image_attachment_id'), table_name='step_image')
op.drop_index(op.f('ix_step_image_step_id'), table_name='step_image')
op.drop_table('step_image')


with op.batch_alter_table('step_execution', schema=None) as batch_op:
batch_op.drop_column('first_focused_at')

op.drop_index(op.f('ix_step_focus_user_id'), table_name='step_focus')
op.drop_index(op.f('ix_step_focus_step_execution_id'), table_name='step_focus')
op.drop_index(op.f('ix_step_focus_instance_id'), table_name='step_focus')
op.drop_table('step_focus')
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""merge issues r2 and risk scenarios heads

Revision ID: 52973e130a6f
Revises: a7b9c1d3e5f7, a7c41d92e803
Create Date: 2026-06-12 12:27:17.477587

"""

from collections.abc import Sequence

# revision identifiers, used by Alembic.
revision: str = "52973e130a6f"
down_revision: str | Sequence[str] | None = ("a7b9c1d3e5f7", "a7c41d92e803")
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
pass


def downgrade() -> None:
pass
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""merge exec focus and part procurement heads

Revision ID: 894e35219894
Revises: b3d1f0a2c4e6, b3c5d7e9f1a2
Create Date: 2026-06-12 14:33:14.188460

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '894e35219894'
down_revision: Union[str, None] = ('b3d1f0a2c4e6', 'b3c5d7e9f1a2')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
pass


def downgrade() -> None:
pass
122 changes: 122 additions & 0 deletions migrations/versions/a7c41d92e803_issue_containment_and_disposition.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Issue containment and disposition signature

Issues R2: the blocking predicate becomes `undispositioned`, never `open`.
- containment (step|op|wo|advisory) + containment_step_id: what an issue holds
- raised_step_id (renamed from step_execution_id) + raised_by_id: where/who found it
- disposition signature: dispositioned_by_id (renamed from
disposition_approved_by_id) + dispositioned_at
- disposition_rationale (renamed from disposition_notes)
- actual (renamed from is_condition) — the should-be/is capture pair
- IssueStatus collapses to open|closed; disposition state is derived
- DispositionType moves to the MIL-STD-1520 set (return_to_vendor →
return_to_supplier; repair and no_defect added)
- step_execution.on_hold rows revert to in_progress: holds are now derived
from undispositioned issues, never stored on the step

Revision ID: a7c41d92e803
Revises: ccf93c36170a
Create Date: 2026-06-12 00:00:00.000000

"""
from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "a7c41d92e803"
down_revision: Union[str, None] = "ccf93c36170a"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# 1. Issue table: new containment/signature columns + renames
with op.batch_alter_table("issue") as batch_op:
batch_op.add_column(
sa.Column("containment", sa.String(20), nullable=False, server_default="advisory")
)
batch_op.add_column(sa.Column("containment_step_id", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("raised_by_id", sa.Integer(), nullable=True))
batch_op.add_column(sa.Column("dispositioned_at", sa.DateTime(timezone=True), nullable=True))
batch_op.alter_column("step_execution_id", new_column_name="raised_step_id")
batch_op.alter_column("is_condition", new_column_name="actual")
batch_op.alter_column("disposition_notes", new_column_name="disposition_rationale")
batch_op.alter_column("disposition_approved_by_id", new_column_name="dispositioned_by_id")
batch_op.create_foreign_key(
"fk_issue_containment_step",
"step_execution",
["containment_step_id"],
["id"],
ondelete="SET NULL",
)
batch_op.create_foreign_key(
"fk_issue_raised_by", "user", ["raised_by_id"], ["id"], ondelete="SET NULL"
)

# The index on the renamed column survives the batch rebuild under its old
# name; rename it to match the column.
op.execute("DROP INDEX IF EXISTS ix_issue_step_execution_id")
op.create_index("ix_issue_raised_step_id", "issue", ["raised_step_id"])

# 2. Data migration
# Disposition enum: vendor → supplier (MIL-STD-1520 set)
op.execute(
"UPDATE issue SET disposition_type = 'return_to_supplier' "
"WHERE disposition_type = 'return_to_vendor'"
)
# Issues previously approved get a best-effort signature timestamp so the
# derived `dispositioned` gate stays true for them.
op.execute(
"UPDATE issue SET dispositioned_at = updated_at "
"WHERE status = 'disposition_approved' AND disposition_type IS NOT NULL "
"AND dispositioned_at IS NULL"
)
# Execution-raised NCs held their step under the old model; preserve that
# as step containment (dispositioned/closed ones don't block anyway).
op.execute(
"UPDATE issue SET containment = 'step' "
"WHERE issue_type = 'non_conformance' AND raised_step_id IS NOT NULL"
)
# Status collapses to open|closed.
op.execute(
"UPDATE issue SET status = 'open' "
"WHERE status IN ('investigating', 'disposition_pending', 'disposition_approved')"
)

# 3. Step holds are derived now — stored ON_HOLD rows revert to in_progress;
# their undispositioned issues keep blocking via containment.
op.execute("UPDATE step_execution SET status = 'in_progress' WHERE status = 'on_hold'")


def downgrade() -> None:
# Best-effort status reconstruction: signed dispositions map back to
# disposition_approved; everything else stays open.
op.execute(
"UPDATE issue SET status = 'disposition_approved' "
"WHERE status = 'open' AND disposition_type IS NOT NULL AND dispositioned_at IS NOT NULL"
)
op.execute(
"UPDATE issue SET disposition_type = 'return_to_vendor' "
"WHERE disposition_type = 'return_to_supplier'"
)
op.execute(
"UPDATE issue SET disposition_type = 'other' "
"WHERE disposition_type IN ('repair', 'no_defect')"
)

op.drop_index("ix_issue_raised_step_id", table_name="issue")

with op.batch_alter_table("issue") as batch_op:
batch_op.drop_constraint("fk_issue_raised_by", type_="foreignkey")
batch_op.drop_constraint("fk_issue_containment_step", type_="foreignkey")
batch_op.alter_column("dispositioned_by_id", new_column_name="disposition_approved_by_id")
batch_op.alter_column("disposition_rationale", new_column_name="disposition_notes")
batch_op.alter_column("actual", new_column_name="is_condition")
batch_op.alter_column("raised_step_id", new_column_name="step_execution_id")
batch_op.drop_column("dispositioned_at")
batch_op.drop_column("raised_by_id")
batch_op.drop_column("containment_step_id")
batch_op.drop_column("containment")

op.create_index("ix_issue_step_execution_id", "issue", ["step_execution_id"])
51 changes: 51 additions & 0 deletions migrations/versions/b3c5d7e9f1a2_part_procurement.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Part procurement declaration (Amendment 6)

Revision ID: b3c5d7e9f1a2
Revises: 52973e130a6f
Create Date: 2026-06-12 19:00:00.000000

Part gains procurement: make | buy | both. The declaration governs which
part-page sections expect content (BOM for make, suppliers/POs for buy);
data always renders regardless. Backfill uses the creation heuristic:
an external PN means the part was bought, otherwise it is made here.

Known asymmetry: Onshape-synced parts store the CAD part number in
external_pn, so the backfill marks pre-existing synced parts buy while
the sync code creates new ones as make (deliberately — a CAD reference
is not a vendor PN). Re-declare affected parts via the edit form.
"""
from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = 'b3c5d7e9f1a2'
down_revision: Union[str, None] = '52973e130a6f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
with op.batch_alter_table('part') as batch_op:
batch_op.add_column(
sa.Column('procurement', sa.String(length=10), nullable=False, server_default='make')
)
batch_op.create_index('ix_part_procurement', ['procurement'])

part = sa.table(
'part',
sa.column('procurement', sa.String),
sa.column('external_pn', sa.String),
)
op.execute(
part.update()
.where(sa.and_(part.c.external_pn.is_not(None), part.c.external_pn != ''))
.values(procurement='buy')
)


def downgrade() -> None:
with op.batch_alter_table('part') as batch_op:
batch_op.drop_index('ix_part_procurement')
batch_op.drop_column('procurement')
Loading