feat: triage workspace + PAT auth + upgrade infrastructure + seed dataset + OpenAPI hardening - #731
feat: triage workspace + PAT auth + upgrade infrastructure + seed dataset + OpenAPI hardening#731aaronlippold wants to merge 714 commits into
Conversation
|
| GitGuardian id | GitGuardian status | Secret | Commit | Filename | |
|---|---|---|---|---|---|
| 33453143 | Triggered | Generic Password | 51d7583 | doc/openapi/paths/users.yaml | View secret |
| 34693140 | Triggered | Generic Password | 7582af0 | spec/contracts/devise_auth_flows_contract_spec.rb | View secret |
| 33788689 | Triggered | Generic Password | a80edde | spec/requests/users/passwords_spec.rb | View secret |
| 34701656 | Triggered | Generic Password | 5092b87 | spec/javascript/components/users/UserProfile.spec.js | View secret |
| 34701655 | Triggered | Generic Password | 5092b87 | spec/contracts/devise_auth_flows_contract_spec.rb | View secret |
| 34701657 | Triggered | Generic Password | df9e0ef | spec/contracts/devise_auth_flows_contract_spec.rb | View secret |
| 34701658 | Triggered | Generic Password | 5092b87 | spec/javascript/components/shared/ConfirmDeleteModal.spec.js | View secret |
🛠 Guidelines to remediate hardcoded secrets
- Understand the implications of revoking this secret by investigating where it is used in your code.
- Replace and store your secrets safely. Learn here the best practices.
- Revoke and rotate these secrets.
- If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.
To avoid such incidents in the future consider
- following these best practices for managing and storing secrets including API keys and other credentials
- install secret detection on pre-commit to catch secret before it leaves your machine and ease remediation.
🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.
There was a problem hiding this comment.
Pull request overview
This PR modernizes the comment/triage workflow and development seeding by introducing a split-pane triage UI (replacing the modal flow), modular/idempotent seed helpers + dev rake tasks, and a DRY’d comment composer state model (ReplyComposerMixin) with supporting factory/test improvements.
Changes:
- Add split-pane triage experience (queue navigation + rule context panel + extracted triage form) and related API support (
include_rule_content, HTML redirect to triage). - Modularize and validate seed pipeline (SeedHelpers, numbered seed files,
dev:*rake tasks, seed verification specs). - Consolidate composer/triage vocabulary utilities and standardize factories/spec fixtures around new traits.
Reviewed changes
Copilot reviewed 83 out of 83 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| spec/services/import/json_archive_importer_spec.rb | Switch review setup to factories/traits for import lifecycle fixtures. |
| spec/services/import/integration/backup_round_trip_spec.rb | Use factory-created reviews in backup round-trip integration spec. |
| spec/services/export/serializers/backup_serializer_spec.rb | Use factory traits for review lifecycle/duplicate-of serialization coverage. |
| spec/services/export/base_spec.rb | Use factory traits for disposition-related export setup. |
| spec/seeds/seed_pipeline_spec.rb | Add end-to-end seed pipeline spec (tagged, truncation strategy). |
| spec/requests/users_spec.rb | Use review factory traits for “My Comments” request fixtures. |
| spec/requests/rules_spec.rb | Use review factory traits for component JSON sidebar/history tests. |
| spec/requests/reactions_spec.rb | Use factory-created reviews/replies for reaction request specs. |
| spec/requests/rack_attack_spec.rb | Use factory-created review for rack-attack reaction target fixture. |
| spec/requests/projects_import_backup_spec.rb | Use factory-created review for project import backup tests. |
| spec/requests/projects_disposition_matrix_export_spec.rb | Use factory traits for triage-status filtered disposition export tests. |
| spec/requests/projects_create_from_backup_spec.rb | Use factory-created review for include_reviews param coverage. |
| spec/requests/project_comments_aggregate_spec.rb | Use factory traits to build aggregate comment fixtures. |
| spec/requests/components_spec.rb | Add HTML redirect expectation for /components/:id/comments; use review factory. |
| spec/requests/components_disposition_matrix_export_spec.rb | Use review factory traits for component disposition export fixtures. |
| spec/requests/component_reviews_spec.rb | Use :component_comment factory trait for component-scoped review tests. |
| spec/rails_helper.rb | Exclude :seed_pipeline specs by default to avoid parallel DB corruption. |
| spec/models/user_spec.rb | Use review factory traits for imported attribution behavior tests. |
| spec/models/reviews_spec.rb | Standardize review creation on factories/traits across invariants + scopes. |
| spec/models/review_polymorphic_commentable_spec.rb | Use :comment/:component_comment traits for polymorphic coverage. |
| spec/models/review_cross_scope_validations_spec.rb | Use review factory traits for cross-scope validation fixtures. |
| spec/models/reaction_spec.rb | Use review factory traits for reaction invariants and summary tests. |
| spec/models/query_performance_spec.rb | Use factory-created reviews to drive component review query tests. |
| spec/models/project_pending_comment_counts_spec.rb | Use factory traits for pending/total count fixtures. |
| spec/models/paginated_comments_pii_spec.rb | Use review factory traits for paginated comment response/reaction counts. |
| spec/models/components_spec.rb | Add assertions for include_rule_content payload shape + updated_at presence. |
| spec/models/component_pending_comment_counts_spec.rb | Use review factory traits for per-component pending count fixtures. |
| spec/lib/seed_helpers_spec.rb | Add unit specs for SeedHelpers API (seed_xccdf/component/review/reply/status/verify). |
| spec/lib/disposition_matrix_export_spec.rb | Switch to review factory traits for export and CSV defang coverage. |
| spec/javascript/mixins/ReplyComposerMixin.spec.js | Add vitest coverage for unified composer state mixin behaviors. |
| spec/javascript/constants/triageVocabulary.spec.js | Add tests for buildStatusFilterOptions(). |
| spec/javascript/components/triage/TriageQueueNav.spec.js | Add tests for 2D triage queue navigation component. |
| spec/javascript/components/triage/CommentTriageForm.spec.js | Add tests for extracted triage form component validation/events. |
| spec/javascript/components/components/ComponentComments.spec.js | Update ComponentComments tests for split-mode behavior and sorting/filter visibility. |
| spec/javascript/components/components/CommentTriageModal.spec.js | Update modal tests to validate delegation to CommentTriageForm and new doTriage API. |
| spec/factory_specs/stig_deadlock_spec.rb | Add regression spec preventing STIG factory deadlock/import side effects. |
| spec/factory_specs/factory_traits_spec.rb | Add spec verifying new FactoryBot traits across multiple models. |
| spec/factories/stigs.rb | Add :skip_rules trait to disable rule import on STIG factory. |
| spec/factories/stig_rules.rb | Ensure stig_rule factory uses a skip-import STIG by default. |
| spec/factories/rules.rb | Add rule status/locked traits used by tests/seeds. |
| spec/factories/reviews.rb | Add review comment/triage/lifecycle traits + auto-membership wiring. |
| spec/factories/projects.rb | Add project membership traits (:with_admin, :with_members). |
| spec/factories/memberships.rb | Add explicit :viewer trait. |
| spec/factories/components.rb | Add component traits for comment period, PoC, released/locked rules. |
| spec/config/seed_idempotency_spec.rb | Update idempotency checks to match modular seed loader + SeedHelpers. |
| spec/blueprints/rule_blueprint_spec.rb | Use review factory traits for comment summary blueprint tests. |
| spec/blueprints/review_membership_blueprints_spec.rb | Use review factory traits for blueprint output coverage. |
| lib/tasks/dev.rake | Add dev:prime/status/verify/reset seed tasks. |
| lib/seed_helpers.rb | Introduce SeedHelpers module (XCCDF import, component seeding, comment seeding, verify/status). |
| docs/superpowers/plans/2026-05-19-comment-interaction-dry.md | Document DRY plan for composer state/event standardization. |
| docs/development/testing.md | Document available FactoryBot traits and usage patterns. |
| docs/development/seed-system.md | Document seed architecture, commands, and SeedHelpers API. |
| db/seeds/data/00_users.rb | Modular seed: demo admin + role-tier + filler users. |
| db/seeds/data/01_projects.rb | Modular seed: demo projects. |
| db/seeds/data/02_srgs.rb | Modular seed: SRG imports via SeedHelpers. |
| db/seeds/data/03_stigs.rb | Modular seed: STIG imports via SeedHelpers. |
| db/seeds/data/04_components.rb | Modular seed: demo components, overlays, dummy stress components, PoC backfill. |
| db/seeds/data/05_memberships.rb | Modular seed: demo RBAC wiring and counter cache reset. |
| db/seeds/data/06_rule_statuses.rb | Modular seed: vary rule statuses for demo coverage. |
| db/seeds/data/10_comments.rb | Modular seed: demo comment threads + triage states. |
| db/seeds/data/11_cross_project.rb | Modular seed: cross-project comment fixtures + triage examples. |
| app/models/review.rb | Improve sync_commentable_from_rule to backfill rule_id from commentable when needed. |
| app/models/component.rb | Add include_rule_content option to paginated_comments, include updated_at, and serialize rule content. |
| app/controllers/components_controller.rb | Redirect HTML /components/:id/comments to triage; add include_rule_content param handling for JSON. |
| app/javascript/mixins/ReplyComposerMixin.vue | Add unified comment composer state + open/close/post hooks. |
| app/javascript/constants/triageVocabulary.js | Add terminal/single-button status sets + buildStatusFilterOptions(). |
| app/javascript/composables/ruleFieldConfig.js | Add canonical FIELD_LABELS mapping for rule context display. |
| app/javascript/components/users/UserComments.vue | Adopt ReplyComposerMixin; switch truncation to CSS; use shared status options builder. |
| app/javascript/components/shared/CommentThread.vue | Switch date formatting to DateFormatMixin helper and remove ad-hoc formatter. |
| app/javascript/components/rules/RulesCodeEditorView.vue | Adopt ReplyComposerMixin and unify composer open/post handling. |
| app/javascript/components/components/ProjectComponent.vue | Adopt ReplyComposerMixin and unify composer open/post handling. |
| app/javascript/components/components/ComponentTriagePage.vue | Add command bar controls for split-mode context/admin panel; wire split-mode events. |
| app/javascript/components/components/ComponentComments.vue | Add split-mode triage integration; default sort by ID; hide filters in split mode; use status options builder; adopt ReplyComposerMixin. |
| app/javascript/components/components/CommentTriageModal.vue | Embed CommentTriageForm; refactor to doTriage API; use DateFormatMixin. |
| app/javascript/components/components/CommentDedupBanner.vue | Switch date formatting to DateFormatMixin helper and remove ad-hoc formatter. |
| app/javascript/components/triage/TriageQueueNav.vue | Add queue navigation UI (prev/next comment + prev/next rule + jump-to dropdown). |
| app/javascript/components/triage/RuleContextPanel.vue | Add rule context side panel with collapsible sections and related comments list. |
| app/javascript/components/triage/CommentTriageForm.vue | Extract triage form component used by modal + split view. |
| app/javascript/components/triage/TriageSplitView.vue | Add split-pane triage view with optimistic-lock support and admin sidebar actions. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| elsif is_stig | ||
| record = Stig.from_mapping(parsed) | ||
| existing = Stig.find_by(stig_id: record.stig_id) | ||
| if existing | ||
| puts " Already exists: #{existing.name} (Stig)" | ||
| return existing | ||
| end | ||
| record.xml = Nokogiri::XML(xml) | ||
| else |
| trait :reply do | ||
| action { 'comment' } | ||
| comment { 'Reply to parent comment' } | ||
| triage_status { nil } | ||
|
|
||
| after(:build) do |review, _evaluator| | ||
| unless review.responding_to_review_id | ||
| parent = create(:review, :comment, user: review.user, rule: review.rule) | ||
| review.responding_to_review_id = parent.id | ||
| review.section = parent.section | ||
| end | ||
| end |
| trait :component_comment do | ||
| action { 'comment' } | ||
| rule { nil } | ||
| section { nil } | ||
| comment { 'Component-level comment' } | ||
|
|
||
| after(:build) do |review| | ||
| unless review.commentable_type == 'Component' | ||
| component = create(:component, :skip_rules) | ||
| review.commentable = component | ||
| review.commentable_type = 'Component' | ||
|
|
||
| if review.user | ||
| project = component.project | ||
| create(:membership, user: review.user, membership: project, role: 'viewer') unless Membership.exists?(user: review.user, membership: project) | ||
| end | ||
| end | ||
| end |
| trait :duplicate do | ||
| triage_status { 'duplicate' } | ||
|
|
||
| after(:build) do |review| | ||
| review.triage_set_by ||= create(:user) | ||
| review.triage_set_at ||= Time.current | ||
| unless review.duplicate_of_review_id | ||
| target = create(:review, :comment, rule: review.rule, user: review.user) | ||
| review.duplicate_of_review_id = target.id | ||
| end | ||
| end |
| comments: { type: Array, required: true }, | ||
| currentId: { type: [Number, String], default: null }, | ||
| }, | ||
| computed: { | ||
| ruleGroups() { | ||
| const groups = []; | ||
| const seen = new Map(); | ||
| for (const c of this.comments) { | ||
| const key = c.rule_id || `component-${c.id}`; | ||
| if (!seen.has(key)) { | ||
| const group = { | ||
| ruleId: key, | ||
| ruleName: c.rule_displayed_name || "(component)", | ||
| comments: [], | ||
| }; | ||
| seen.set(key, group); | ||
| groups.push(group); | ||
| } | ||
| seen.get(key).comments.push(c); | ||
| } | ||
| return groups; | ||
| }, | ||
| currentPosition() { | ||
| for (let gi = 0; gi < this.ruleGroups.length; gi++) { | ||
| const group = this.ruleGroups[gi]; | ||
| for (let ci = 0; ci < group.comments.length; ci++) { | ||
| if (group.comments[ci].id === this.currentId) { | ||
| return { ruleIndex: gi, commentIndex: ci }; | ||
| } | ||
| } | ||
| } | ||
| return { ruleIndex: -1, commentIndex: -1 }; | ||
| }, |
| props: { | ||
| rows: { type: Array, required: true }, | ||
| initialCommentId: { type: [Number, String], required: true }, | ||
| componentId: { type: [Number, String], required: true }, | ||
| effectivePermissions: { type: String, default: null }, | ||
| adminPanelOpen: { type: Boolean, default: false }, | ||
| contextMode: { type: String, default: "commented" }, | ||
| }, | ||
| data() { | ||
| return { | ||
| activeCommentId: this.initialCommentId, | ||
| isDirty: false, | ||
| saving: false, | ||
| conflictAlert: null, | ||
| adminAction: null, | ||
| adminAuditComment: "", | ||
| adminConfirmationId: "", | ||
| adminTargetRuleId: null, | ||
| }; | ||
| }, | ||
| computed: { | ||
| sortedRows() { | ||
| return [...this.rows].sort((a, b) => a.id - b.id); | ||
| }, | ||
| activeComment() { | ||
| return this.sortedRows.find((r) => r.id === this.activeCommentId) || null; | ||
| }, | ||
| canTriage() { |
| it 'creates a review comment using FactoryBot' do | ||
| review = described_class.find_or_seed_review( | ||
| rule: rule, user: user, section: 'check_content', | ||
| comment: 'Test comment for seed helper' | ||
| ) | ||
| expect(review).to be_persisted | ||
| expect(review.action).to eq('comment') | ||
| expect(review.triage_status).to eq('pending') | ||
| end |
| async onTriageSave(decision) { | ||
| await this.doSave(decision, false); | ||
| }, | ||
| async onTriageSaveAndNext(decision) { | ||
| await this.doSave(decision, true); | ||
| }, | ||
| async doSave(decision, advance) { | ||
| if (!this.activeComment) return; | ||
| this.saving = true; | ||
| this.conflictAlert = null; | ||
| try { | ||
| const payload = { | ||
| triage_status: decision.triage_status, | ||
| expected_updated_at: this.activeComment.updated_at, | ||
| }; | ||
| if (decision.response_comment) { | ||
| payload.response_comment = decision.response_comment; | ||
| } | ||
| if (decision.triage_status === "duplicate") { | ||
| payload.duplicate_of_review_id = decision.duplicate_of_review_id; | ||
| } | ||
|
|
||
| const triageRes = await axios.patch(`/reviews/${this.activeComment.id}/triage`, payload); | ||
| this.$emit("triaged", triageRes.data.review); | ||
|
|
||
| if (triageRes.data.response_review) { | ||
| this.$emit("response-posted", { | ||
| parentId: this.activeComment.id, | ||
| responseReview: triageRes.data.response_review, | ||
| }); | ||
| } | ||
|
|
||
| if (!SINGLE_BUTTON_STATUSES.has(decision.triage_status)) { | ||
| const adjRes = await axios.patch(`/reviews/${this.activeComment.id}/adjudicate`, {}); | ||
| this.$emit("adjudicated", adjRes.data.review); | ||
| } | ||
|
|
|
Some notes after review:
Some side notes about locked fields:
|
|
That 3NF doc design is a big enough change to warrant its own PR. Going to cherry pick it and make a new PR off main. |
|
@wdower — heads up, this PR is still in progress. All your review feedback items from the first round are addressed (admin inline, toggle moved, rename, locked comments, staleness badge, etc.) but I still have a few cards to finish:
Will push these in the next session. Don't need a full re-review yet — just wanted you to know the status. — Aaron |
Added/Changed/Fixed sections covering three-column layout, progress bar, DRY color palette, ARIA accessibility, InfoTooltip/InfoNotice adoption, and bug fixes Authored by: Aaron Lippold<lippold@gmail.com>
The stored XCCDF document is authoritative and backup restore
re-derives rows from it — a row whose columns disagree with its own
header would silently change identity across a backup cycle (the
package-registry lesson: derive the index from the artifact, and
check it).
- The three header composition formulas (V{v}R{r} version string,
display name, benchmark-id underscoring) each get ONE shared class
method; SRG and STIG from_mapping and the XCCDF exporter call them.
Fixes a latent bug in BOTH from_mapping copies: single-character
reads truncated multi-digit versions (V10R12 displayed as
"Ver 1, Rel 2")
- Xccdf::BenchmarkHeader: SAX header reader that stops at the first
Profile or Group — identity fields without the rule-bulk parse
- Consistency validation on create/xml change: srg_id, title, and
version columns must match the stored header; mismatch is a
rejection naming both values
- skip_rule_import intent flag on the after_create import — the
release path populates catalog rows from the release copy, never
the XML import; the factory :skip_rules trait now uses the real
flag and stamps its header to match its attributes (raising loudly
if the fixture ever drifts)
- Spec support cleanup the invariant forced into the open: the
global skip_callback hacks in the search spec leaked across
parallel workers (54 apparent failures from one bug) — replaced
with per-instance factory mechanics; legacy odd-version rows are
now simulated via update_column; the deletable-SRG contract row is
factory-built instead of hand-edited onto another document's XML
Authored by: Aaron Lippold<lippold@gmail.com>
In this domain the swept word means NIST 800-53 control groupings and nothing else — the remaining uses in export spec prose, one formatter comment, and the mailer spec requirements block are reworded. Behavior untouched; the enforcement assertions and the NIST control usage are the only remaining occurrences. Authored by: Aaron Lippold<lippold@gmail.com>
The authored generator carried the catalog row's own document id onto generated requirement rows (a dup artifact — set_rule_id skips non-blank ids), defeating the local numbering design: largest_rule_id's TO_NUMBER, working display names, and relocation renumbering all assume six-digit ordinals. Numbers are assigned explicitly in the generation loop — the bulk import validates every row before any insert, so the per-row callback would hand them all the same next number. The catalog rows are read in canonical order so numbering is deterministic and follows the source document's requirement order, the same convention the stig import path uses; a reverse-insertion spec pins that (it fails under insertion- or database-order numbering). Authored by: Aaron Lippold<lippold@gmail.com>
The V-/SV-/F-/C- compositions for draft-published requirements lived inline in the XCCDF formatter and again in the InSpec control tags. PublishedIdentifiers is now the one home; both emitters call it. The byte corpus proves the XCCDF emission identical; the InSpec specs pin the tag values. Why one home matters: the basing import joins a document's Rule element ids against catalog rule_id columns — emission and the release catalog copy must agree byte-for-byte, so the formula may exist exactly once. Authored by: Aaron Lippold<lippold@gmail.com>
ReleaseAttachmentService lands the release the registry way, in one
transaction: mint identifiers, generate the published XCCDF, create
the catalog SecurityRequirementsGuide with columns derived FROM that
artifact (the stored document is authoritative; the header-consistency
validation enforces the match), then populate rules through the
release copy. The released entry is shaped exactly like an uploaded
SRG — basing and is-latest work unchanged, proven by round-trip specs.
- Component integer version/release surface as catalog V{v}R{r}
through compose-then-extract; a component missing either is refused,
never defaulted
- The catalog copy now stamps each row's rule_id with the published
Rule element id — the join key the basing import matches against
the parsed document (uploaded shape all the way down)
- ReleaseCopyService exposes publishable_rows and
component_validation_errors: one selection and one component-side
check shared by minting, export, and the copy
- Release changelog: structured removals data from executed relocation
records (identifier, destination abbreviation, executed date) plus a
plain-text renderer; storage and serving belong to the endpoint
Authored by: Aaron Lippold<lippold@gmail.com>
- useRelocations now owns the labelled destination list: endpoint rows named by SRG (with next-release suffix) merged with outside-Vulcan marker abbreviations, deduped and abbreviation-sorted - proposalDestinationOptions excludes the component's own SRG for the propose picker; the backlog keeps it (it is the receiver's inbox) - consumers render the shared options instead of deriving their own: the backlog panel's local tokenOptions and both editor pages' own-token filters are deleted - FilterDropdown renders disabled with its placeholder when it has zero options instead of opening an empty menu - both editor pages eagerly fetch destinations at created so the vocabulary is present before any panel opens - specs pin the merged labelling, the sort order, the propose subset, the zero-options guard, and the view-page fetch wiring Authored by: Aaron Lippold<lippold@gmail.com>
- POST /components/:id/release (author-authorized) runs the whole release in one transaction: preflight blockers, mint identifiers, generate the published XCCDF, attach the catalog entry, copy rows, then flip released — a failure anywhere rolls everything back - the response toast carries the new catalog SRG and the structured changelog (removals from executed relocations); blockers return a 422 toast listing every unmet requirement - Component gains the via_release_flow intent flag and a model guard: an srg-kind component can no longer be released by a plain released:true update, which would have stranded it outside the catalog; the flag is not mass-assignable - useConfirmRelease kind-routes the existing Release button: srg goes through the new endpoint, stig keeps the PATCH path unchanged - OpenAPI path + ComponentReleaseResponse schema documented and bundled, with a contract test validating the live response shape - request specs cover success, blockers, authz, and the guard; the two existing specs that built post-release fixtures via plain updates now set the intent flag explicitly Authored by: Aaron Lippold<lippold@gmail.com>
- BackupSerializer emitted attribution only from the user associations, so a review whose author was already on the imported columns (prior import without a local match, or a destroyed user) exported blank — and the importer then skipped the row entirely; attribution died on the second export/import round trip - serialize_review now derives each of the three attribution pairs (commenter, triager, adjudicator) through one fallback: the linked user wins; the *_imported_email/_name snapshot fills in when the FK is nil; archive keys unchanged - specs pin all three fallbacks with exact values, the linked-user precedence, and a full export-then-import round trip proving the carried attribution lands back on the imported columns Authored by: Aaron Lippold<lippold@gmail.com>
- the core SRGs are defined — exactly three, DISA-published (Operating System Core, Network Core, Application Core); core-ness is recognition of those documents, never a user designation, and nothing in the product could set the flag before this - SecurityRequirementsGuide gains CORE_SRG_IDS (the three benchmark ids read from the real documents) and one-directional recognition on create: a matching id flags the record core, a non-matching id never unsets an explicitly-set core — the model is the one seam, so upload, seeds, and every other creation path recognize alike - the three documents land as standard seeds; the seed-file inventory guard's pinned list grows to match - an idempotent data backfill flags rows that predate recognition, on any release of the three documents (benchmark ids are stable across releases); migration spec pins the flip, the derived-row guard, the idempotent re-run, and the intentionally irreversible down Authored by: Aaron Lippold<lippold@gmail.com>
- One-off, plan-driven pure carrier: replays every planned review comment onto the redo component's rows with imported attribution, provenance markers, and original-commentable lineage; writes no requirement states - Rows insert through Review.insert! and re-validate against the import-integrity contract; threads carry in two passes so replies land under their carried parents; replies ride with their thread regardless of plan listing (the thread is the carry unit) - Labeled research notes ride alongside team comments so reviewers can tell the transition context apart from the original commentary - Re-run refusal via the provenance guard; rake entry point takes the plan path and target component id and reports source -> target per row with counts and skip reasons - 8 service specs cover attribution, threading, zero-state writes, planned-ids-only, re-run refusal, full report shape including the target-not-found branch, and the srg-kind guard; independently review-verified Authored by: Aaron Lippold<lippold@gmail.com>
The sidebar SRG ID column blanked for authored SRG requirements: the serializer never exposed srg_id, so the identifier reached the editor header (which reads version) but never the requirement list. Serve srg_id from the row's own version so requirement lists stay kind-agnostic across document kinds. Schema, bundle, and the contract example move with it. Authored by: Aaron Lippold<lippold@gmail.com>
A not-applicable SRG requirement showed only the status and its justification editor, so the author had to switch the requirement back to applicable to read what was being excluded. Title, severity, the reference identifiers, and the vulnerability discussion now render read-only beside the editable fields, keeping the spirit of the requirement in view while the justification is written. Authored by: Aaron Lippold<lippold@gmail.com>
The sidebar comment indicator becomes a button that opens the full comment list for that requirement. Adding a comment or replying hands off to the page's existing composer, so both editor surfaces keep one composer instance and one posting path. Each sidebar row also carries a status dot. Colors come from a new rule-status design-system layer that mirrors the triage tints: one data-attribute block per status, core tokens only, neutral fallback for unrecognized values. Nested rows resolve their status from the loaded requirements, since satisfaction references carry none. Authored by: Aaron Lippold<lippold@gmail.com>
The question "which SRG requirement does this correspond to?" was being answered by callers reaching through the association themselves, so the same derivation was written out in nine places across models, blueprints and the export serializer. Each copy had to know that a STIG rule keeps that answer on its source requirement while an authored SRG requirement holds it directly, which is exactly the sort of knowledge that ends up diverging. The hierarchy answers it once instead: BaseRule declares srg_identifier and refuses a default, and each subclass supplies its own. The callers just ask. A blueprint that had already drifted from the others now gets the same answer as the rest, which is what let the sidebar sort on a field the serializer actually serves. Authored by: Aaron Lippold<lippold@gmail.com>
Each toggle was declared in four unsynchronized places: a defaults object, a restorable-keys list for persistence, the active-filter count, and the bar's own markup. Adding a toggle meant editing all four, and when requirements gained a second document kind the toggles that do not apply to it were left switched on and silently inert, because nothing described which kind a toggle belongs to. One registry now describes each toggle once, in three groups, with the status group generated from the runtime vocabulary rather than restated. The bar renders whatever the registry declares, persistence and the active count read it, and a toggle that does not apply to the current kind renders disabled with a tooltip saying why instead of pretending to work. A second filter pipeline that had gone dead, with search semantics that no longer matched the live one, is removed rather than left to be found later. Also folds in the sidebar sort, which shares this file: it keys on the identifier the serializer actually sends instead of one it never did, so the SRG sort applies to the rows the user sees. Authored by: Aaron Lippold<lippold@gmail.com>
The sidebar took a share of a twelve-column grid, so its width tracked the viewport while its content did not: a requirement identifier is a fixed string, and at the widths people actually work at the column was too narrow for it. The list had been compensating by shrinking its own font, which is a workaround for the sizing being wrong. Panels are now sized by role. A sidebar holds content of known width and gets a bounded token from the design system; everything else divides what is left. That is the same mechanism Bootstrap uses for its own columns, with a measured token instead of a percentage, so the font compensation is gone and no consumer passes a column count any more. The floor is measured against the worst row the feature can produce, not the common one: a nested parent carrying a chevron, status dot, identifier, child-count badge and the icon strip. It also covers the panel's own chrome, including the scroll gutter -- a browser drawing overlay scrollbars takes no width, but a classic scrollbar takes about seventeen pixels out of the same panel and the list always scrolls. A floor measured only under overlay scrollbars cut six of thirteen identifiers once a classic one was forced. The trailing icon strip is also pinned against shrinking, so it can no longer be squeezed into wrapping and doubling the row height. Authored by: Aaron Lippold<lippold@gmail.com>
The test was named for all three places a requirement row renders its trailing icons, but its fixture built only two of them and asserted the count was greater than zero. One site had no coverage while the name claimed otherwise, and a lower bound would have passed even if a row rendered a shrinkable strip — which is the row that wrapped and caused the height regression in the first place. It now builds an open rule, an all-rules parent and a nested child in one wrapper and pins the count at exactly three, reusing the setup an adjacent test in the same file already established. Authored by: Aaron Lippold<lippold@gmail.com>
Opening a component asked the database for thousands of rows one at a time. The editor issued 6045 queries and took ten seconds; a component twice the size cost roughly twice as much, so the pages got slower as teams did more work in them. Nothing described what a serializer reads. Each field was free to reach through an association while it rendered, and because the output is identical either way, no test could see it happening. The eager-loading that was meant to prevent this could only act on dependencies that were declared, and almost none were. Every field that reaches for data now says so where it is defined, and the fetch plan is derived from the serializer itself rather than restated by callers, so the two cannot drift apart. Three sources accounted for nearly all of it: reply counts and comment authors, resolved per comment; the source requirement and owning component behind each satisfaction link, resolved per link; and the audit trail. The audit trail is different in kind. It is assembled per record rather than read from an association, so it cannot be fetched for a collection in one query no matter how it is declared — and it is only ever shown for the single requirement a user has open. It is now sent when asked for: the per-requirement endpoint asks, a component's rules list does not. The sidebar already fetched a requirement's detail when selecting one that lacked it; the component view page emitted that request but had nobody listening, so it gained a handler. /components/:id/edit 6045 queries / 9934 ms -> 62 / 1636 ms /components/122 2196 queries / 3826 ms -> 38 / 348 ms /components/:id/triage 1619 queries -> 62 / 1017 ms Counts no longer grow with requirements, comments or satisfaction links; a test pins each of those axes by holding the other two equal. Serialized output is byte-identical for both document kinds and both views, so nothing consuming the API sees a change — except the audit trail leaving the collection, which the contract test now asserts is absent so it cannot quietly return. Authored by: Aaron Lippold<lippold@gmail.com>
The component copy path built its id-mapping CTE by interpolating each id pair into a VALUES list. The values were integer primary keys, so the statement was never actually injectable, but the SQL text changed with every row count and the scanner could not tell the difference — which is why four suppressions had accumulated around it. Pass the ids as two bigint[] parameters and let unnest zip them back into rows. Every statement in the copy path is now a fixed string with no interpolation, so it also stops defeating statement caching. Drops the four suppressions the old construct required; the remaining one covers a separate file still using the older pattern. Authored by: Aaron Lippold<lippold@gmail.com>
| auth = request.headers['Authorization'] | ||
| return nil unless auth | ||
|
|
||
| match = auth.match(/\AToken\s+(.+)\z/i) |
Brings in the image CVE refresh (#744): activestorage 8.0.5.1, loofah 2.25.2, rails-html-sanitizer 1.7.1, oj 3.17.5, Ruby 3.4.10, plus the Dockerfile and CI changes that came with it. bundler-audit is clean again, which unblocks the branch's pipeline. Conflicts resolved by taking the better side of each, not one side throughout: .tool-versions — Ruby 3.4.10 from master, but Node 24 from this branch. Master's file said 22.13.1 while its own Dockerfile builds 24.18.0; the Dockerfile is what actually runs, so the branch value is the correct one. Worth fixing on master too. Gemfile — both sides kept: this branch's climate_control, has_scope and pagy alongside master's faraday. Gemfile.lock — based on this branch's lock, with only the four gems bundler-audit flagged updated on top. Basing on master's lock instead looks equivalent but silently reverts rack 3.2.6 to 2.2.23, and this branch relies on the 3.1 status-code names in 72 places; that route fails 162 examples. yarn.lock — regenerated from this branch's package.json. Master's axios pins do not carry over because this branch replaced axios with ky. Full backend suite 4109 green, JS 3613 green, build, lint, Brakeman and bundler-audit all clean. Authored by: Aaron Lippold<lippold@gmail.com>
|
|
||
| - name: Publish OpenAPI spec to Scalar registry | ||
| run: | | ||
| npx @scalar/cli auth login --token "$SCALAR_TOKEN" |
|
|
||
| - name: Publish OpenAPI spec to Scalar registry | ||
| run: | | ||
| npx @scalar/cli auth login --token "$SCALAR_TOKEN" |
| # the Rails app's Vue 2). Install only docs deps in CI. | ||
| cd docs | ||
| yarn install | ||
| yarn install --frozen-lockfile |
| uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 | ||
|
|
||
| - name: Install root dependencies (for redocly CLI) | ||
| run: yarn install --frozen-lockfile |
A dual-lineage component lost its secondary source on restore — the archive itself was incomplete, so no later restore could recover it. The backup now serializes the full declared parent set the same portable way based_on travels (srg_id plus version, never a database id), and the restore rebuilds the join rows through the parent-set seam so the membership validations see them on first save. The manifest was part of the same defect: pre-flight validation and include_srg bundling both read only the singular based_on, so a missing secondary surfaced after the component was already built and a bundled archive was not self-contained for its secondary. Both now cover every source. A wholly-absent secondary warns instead of failing the restore — unlike based_on, its absence costs one lineage link while everything else restores intact. The key is additive with no format-version bump: deployed validators hard-reject unknown versions, and readers branch on key presence, not version — the same compatibility signal document_type uses. Archives from before the key restore unchanged, and a malformed value falls back to based_on alone. Authored by: Aaron Lippold<lippold@gmail.com>
The disposition matrix queried requirements through the Rule STI subclass at three sites, so every SRG requirement's comments were silently missing from the export — and a fourth site surfaced when the end-to-end test ran: the row builder resolved its requirement through the Rule-typed association, which is nil for authored requirements, and crashed on the first SRG comment. All four now go through the kind-agnostic seams: the live-requirements scope for the queries, and Review#requirement — the model's own preferred resolution — for the row builder. A regression guard pins the invariant across the four triage/comment/disposition files: any reintroduced STI-subclass query fails with the offending file and line. Only live code is matched, so prose documenting the anti-pattern by name stays legal. Comments on soft-deleted requirements now leave the export — the seam's documented semantics, pinned by test so the exclusion stays a decision rather than an accident. Authored by: Aaron Lippold<lippold@gmail.com>
Settings is a process-wide singleton, and these specs mutated four consent keys while restoring only one — the leaked String version failed the Settings-defaults type pins whenever example randomization ran them first in a worker. The banner contexts had the same shape, silently leaking colors and text. Every mutation now captures its originals and restores them all, so a yml default change can never diverge from the restore. The only other spec mutating Settings directly already reloads after each example. Authored by: Aaron Lippold<lippold@gmail.com>
Find scoped its search through the Rule STI association, so it returned nothing for SRG components. Only the base scope was actually kind-specific — every searched column lives on base_rules and both child tables key on base_rule, so one field list serves both kinds — and it now routes through the requirements seam. The action also re-queried the component its before_action had already loaded; the dead lookup is gone. Blueprint selection moves to ComponentBlueprint.requirement_blueprint, the one seam both full-collection serialization and find pick their blueprint through, so matches serialize in the shape their kind's editor consumes. The find path's response schema becomes the same oneOf the component editor response already uses, and the searched requirements now preload their blueprint plan. Authored by: Aaron Lippold<lippold@gmail.com>
The compare endpoint diffs exactly one field — inspec_control_file — and SRG requirements carry no InSpec. An SRG or mixed-kind pair now guards with a clear 422 instead of diffing two empty sets and reporting every requirement unchanged. The existing 4XX ErrorResponse wildcard already covers the response shape. The revision-history diff walked the Rule-typed collection, so SRG component versions compared as two empty sets and every history entry showed no changes. It now traverses the kind-agnostic requirement set via a shared requirement_diff_fields helper; basic_fields is hoisted to BaseRule (row-local check and fix text) and Rule keeps its satisfaction-aware override. The satisfied_by eager-load stays on the STIG branch only, where the association exists. - Request specs cover SRG-vs-SRG and mixed-kind compare 422s - Request spec pins the SRG history diff: added and updated requirements surface with base/diff titles Authored by: Aaron Lippold<lippold@gmail.com>
Component destroy collected rule ids with a Rule-typed query, so an SRG component's authored SrgRules never entered the bulk cleanup and deletion fell to the per-row cascade. The SQL log showed the STIG path was no better off: destroy loaded the full eager graph and destroy! re-walked the stale in-memory rules association per row after the bulk deletes — and that accidental re-walk was the only thing cleaning references, which has no database foreign key. - Collect ids via BaseRule.unscoped so both kinds (and tombstoned rows) take the same fast path - Load destroy with the basic loader — nothing about deleting needs the eager graph, and the stale association was the re-walk's cause - Add RequirementRelocation source-side rows (their foreign key restricts deletes) and Reference rows (no foreign key at all) to the bulk cleanup list - Pin both kinds with query-count invariance specs: the destroy query count must not scale with the requirement count, the only honest observable separating bulk from cascade Authored by: Aaron Lippold<lippold@gmail.com>
Brakeman's 60-day EOL window for Rails 8.0 (support ends 2026-10-07) opened today and fails every CI run and push. The Rails 8.1 upgrade is planned master-first; this acknowledged-warning entry bridges the gap and comes out when that upgrade merges into this branch. Authored by: Aaron Lippold<lippold@gmail.com>
|




Summary
Major feature branch: 247 commits, 668 files changed, 53K insertions. Builds on the triage split-pane workspace (PR #717 follow-up) and adds infrastructure standardization, API authentication, upgrade system, seed data, and documentation.
Core Features (earlier commits on this branch)
New in this push
{title, message: Array, variant}across all mutation endpointsvulcan_development/test/productionnaming, test DB name hardcoded to prevent collision, port registry (5435)drop_invalid_reviewsnow deletes children before parents (RESTRICT safety)Bug fixes
Test plan
bin/parallel_rspec spec/— full backend suiteyarn test:unit— Vue component testsyarn lint:ci— 0 warningsbundle exec rubocop— 0 offensesyarn openapi:bundle && yarn openapi:lint— 0 errorsbundle exec rspec spec/contracts/— 107 contract testsyarn docs:build— VitePress builds cleanbundle exec brakeman— 0 warningsAuthored by: Aaron Lippoldlippold@gmail.com