Repository navigation
feat(authz)+fix(security): tenant-scoped auditable OIDC mapping, 감사 2라운드 25건 수리 (tenant 격리·증빙 무결성·자원 상한) - #102
Open
claude[bot] wants to merge 207 commits into
Conversation
Refresh the product-technical gap baseline against the 2026-09-07 GitHub API state. Open PR count is 35. Material changes since the 2026-09-02 baseline: - #81 (cryptography 50.0.0, the repo-wide trivy-fs unlock) is now Draft at the same head ce40bd8. The first item of the unlock stack has removed itself from the merge queue, so trivy-fs inheritance failures below it are expected until it returns to Ready. Recorded explicitly so nobody duplicates the bump into another PR; #81 stays the single writer. - #90 (public Pages landing source) closed without merging. Its gap is reopened in the operator-facing table rather than dropped. - #94 closed without merging; #93 states it supersedes #94 and carries the docs-only fuzz filter, so this is succession, not loss. - New: #92, #93, #96, #97, #99, #100. Heads moved for #72 and #89. - #75 targets the #73 branch, not main; noted so its ordering is legible. Correct a reference defect: the lineage row cited LineageWeave as bare "#74", but bare #74 resolves to this repository's issue #74 (framework-neutral data management evidence, implemented by #75). Cross-repository references use owner/repo#num. Add a CI governance section. #93 and #100 both add tests/test_workflow_concurrency_contract.py and both modify fuzz.yml and tests.yml, so whichever merges second hits an add/add conflict. Each also carries delta the other lacks: #93 has Scorecard delegation, ghost workflow disabling and the docs-only fuzz skip; #100 has the Draft and closed PR runner-job guards. Record the required {workflow}-{repository}-{PR number} concurrency contract and resolve by integrating the delta under one writer, not by closing either PR. Docs only. No product or workflow code changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
Self-correction to the previous commit. It recorded #90 (public Pages landing) as closed-without-merge and therefore a reopened gap needing reopen or a successor PR. That reading was wrong. #90's body states its publication and license-boundary value was folded into #72 at commit 10096ce, and #72's file list confirms it adds docs/index.md (48 lines). The closure is full delta succession, so #90 must not be revived and no second Pages-source PR should be created. Corrections: - Removed-PR table: #90 is proper succession, not a recovery target. Head corrected to its real value 3a23f87. - Operator-facing gaps: the landing is not a PR-less gap; it is waiting on #72, which is Draft. Reframed as "land #72". - Merge order item 11 and the trailing squash note no longer instruct a squash of a closed PR. - #72's row now records that it is the single writer for the landing. Docs only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
main has no docs/adr/ directory yet, but five open PRs each add ADRs under the same four-digit ids for unrelated decisions. ADR 0001 is claimed by #72 (product-authority-boundary), #73 (ontology-catalog-plane), #88 (measurement-context-registry) and #96 (openmetadata-anti-corruption-boundary). ADR 0002 is claimed by #72 (semantic-web-grounding), #73 (corporate-master-resolution-owner) and #97/#99 (openmetadata-admission-preview-receipts). #97 and #99 sharing a path is expected since they are the same succession lane. Whichever PR merges first takes the number; the rest either hit add/add conflicts or quietly land a different decision under an id that is already cited elsewhere. This baseline itself cites #73's corporate-master decision as "ADR 0002", which would silently point at #72's semantic-web-grounding if #72 merged first. Per the repair rule a number collision is a rename, not a redesign, so no PR is closed and no content changes. Records a default allocation derived mechanically from the merge order already documented here, and notes that the allocation follows the order if the order changes. Docs only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
Cross-reference the tracking issues so the baseline points at them rather than restating the finding in isolation. - The ADR number collision section now names issue #103 as its tracker. - #81's row now cites issue #101 (cryptography 50.0.0 for CVE-2026-69247) and records that the issue is still open, so the CVE is unresolved while #81 sits in Draft. Docs only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
Addresses Devin Review on this PR: the previous edit removed the bare #74 citation but replaced it with only a format rule, so the lineage work had no reference a steward could follow. Link the owning repository directly (ContextualWisdomLab/LineageWeave, verified to exist and public) so the lane stays trackable, keep the note explaining why bare #74 was wrong (it resolves to this repository's issue #74), and state that the specific issue or PR number is not recorded here so whoever confirms it fills it in as ContextualWisdomLab/LineageWeave#<num> rather than a bare number. Docs only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
Second finding in the ADR lane, separate from the number collision. main has no docs/adr/ directory, yet #73's two ADRs already declare Accepted: 0001-ontology-catalog-plane.md ("Status: Accepted", dated 2026-08-18) and 0002-corporate-master-resolution-owner.md ("Status: Accepted target contract"). #73 is still open and, per this baseline, failing trivy-fs and strix. Every other ADR-bearing PR uses a correct pre-merge status: #72 Draft, #88 Proposed, #96 Proposed, #97/#99 Proposed. #73 is the only outlier. A decision that exists solely inside an unmerged PR but claims Accepted makes decisions on the protected branch indistinguishable from proposals when reading the documents alone. Queued, pending and unmerged states are not acceptance evidence. The repair is one status line per file, not a content change: hold Proposed until #73 lands, then raise to Accepted. Also confirm the #97 to #99 succession from primary evidence rather than inference: #99's ADR states "대체 대상: PR #97의 유효 delta를 검증 후 승계", so the merge-order note no longer hedges. Notes that #73's ADRs already use the owner/repo#num convention for cross-repository references, which is why the lineage row's bare #74 was the exception rather than the local norm. Docs only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
…aphy fix #57 is titled and described as a github/codeql-action/upload-sarif bump from 4.36.3 to 4.37.6, and its body is unmodified Dependabot boilerplate. Its actual content is 18 commits across 18 files, mostly unrelated to that bump: a new outbound HTTPS boundary module (src/sdp/network_security.py, +128), OIDC JWKS and observability sink destination validation, a differential coverage gate (tools/check_diff_coverage.py, +143), three workflow files, a doctoring record, and cryptography 49.0.0 to 50.0.0. The merge commits show this was deliberate ("Merge security gate fix into CodeQL action update"); only the title and body were never updated. Three consequences, now recorded: 1. Classification. This baseline itself listed #57 under the Dependabot group as "dependency currency", which is wrong and would lead a steward to treat a security and CI change as routine. Split into its own row. 2. #81 single-writer violation. #57 changes cryptography==49.0.0 to 50.0.0 in requirements.txt, which is the entire purpose of #81. The CVE-2026-69247 fix now exists in two PRs while this document tells readers not to duplicate it. Both must not land together. 3. The fuzz.yml concurrency collision is three-way, not two-way. #57's group expression is a third variant, fuzz-${{ ... }}, which does not satisfy the {workflow}-{repository}-{PR number} contract that #93 and #100 both satisfy. No PR is closed. Records the three decisions that have to be made separately: pick one writer for the cryptography bump, fold #57's concurrency edit into the #93/#100 resolution, and correct #57's title and body so reviewers can see what it contains. Docs only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
…sults Records what was mechanically checked across all 36 open PRs and what each check returned, so the next reader does not repeat the same sweep. Findings already captured elsewhere in this document: ADR number collisions, premature Accepted on #73, the #57/#93/#100 single-writer overlap on the workflow files, and the duplicated cryptography bump between #81 and #57. Clean results worth recording because they are otherwise invisible: - Missing tests: none. All 18 PRs that touch src/ also touch tests/. - Supply-chain hardening: none reverted. No open PR introduces an unpinned action (no 40-character SHA) or an image without a digest. - Source-line citations: accurate. catalog.py:25/414/445, evidence.py:37-48 and api.py:804-814 all resolve as cited on origin/main. That last check also confirms this document's security claim from primary source rather than restatement: browse preview trusts payload.get("user", "anonymous") with no token verification at e48aa13, so a caller can assert an arbitrary identity. Docs only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
Answers the standing "release if releasable" question with evidence instead of leaving it unexamined. The answer is no, and the reason is the CVE. main at e48aa13 still pins cryptography==49.0.0, the version carrying CVE-2026-69247. The fix exists only in #81 (Draft) and #57 (mis-titled, see its section); neither is merged, and tracking issue #101 is open. Tagging a release now would ship a known-vulnerable pinned dependency to buyers. Settle the cryptography single writer and land it before any release is discussed. Two adjacent gaps surfaced while checking and are now recorded in the operator-facing table: - No CHANGELOG.md exists anywhere in the repository, despite 100 commits on main and pyproject.toml declaring version 0.3.0. There is no way for a stakeholder to see what changed when. - No git tag and no GitHub release has ever been cut, so the declared version string has no corresponding immutable artifact and no commit can be identified as 0.3.0. Both are natural work for the first release after the CVE clears. Neither should be done by cutting a tag while the CVE is open. Docs only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
Corrects a diagnosis this document and its readers have been carrying: that the queue is stalled behind review workflows that have not run. Measurement on 12 sampled PRs says otherwise. The review workflows run and pass. On #51 and #82, opencode-review, strix, noema-review and dependency-review all report success on the current head. Waiting for checks is therefore not the path forward. What is absent is approval. All 12 sampled PRs have zero APPROVED reviews on their current head. #51 has accumulated 28 reviews, of which 27 are COMMENTED and one is DISMISSED, and none is an approval. So blocked here means "no approving review", not "a check failed" and not "a check is still queued". That distinction changes what to do: this does not clear by waiting, it clears when an approver records a verdict against the current head. No agent can approve its own or another PR, or bypass branch protection, so this is a human decision. Also records that #88 is shaped differently: 4 check runs and no review gate checks at all, unlike the other sampled PRs which carry 3 to 7. That one needs separate investigation into why required checks are not attached. Docs only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
…laim Corrects the section added in the previous commit. It said the review checks pass and only approval is missing. That held for the two PRs sampled but does not generalize, because the earlier filter only matched review-gate check names and so missed failures elsewhere. Full census over 16 PRs, counting every failing check: - Zero APPROVED reviews on the current head: 16 of 16. No exception. - Failing checks as well: 12 of 16. trivy-fs is the most common (7), which is main's cryptography==49.0.0 inherited through the merge ref, confirming by measurement what this document predicted about #81. The useful consequence is that the queue splits. #51, #35 and #59 have no failing check at all and are blocked solely on the missing approval, so a single verdict on each would let them merge with no other repair. Everything else needs its checks fixed first, and landing one cryptography writer clears seven trivy-fs failures at once. #88 is characterised correctly now: it is not check-clean in the same sense. It carries only 4 CodeQL-family runs and the required workflows never attached, so its checks did not run rather than passed. Docs only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
… CHANGELOG Two corrections plus one root cause, all from measurement. #88's required checks are neither failing nor unattached. On its current head 8e281de, four workflow runs sit at action_required with zero check runs: Tests, fuzz, SAST Semgrep and Security Scan. The previous head 39efbb8 ran all four normally, and tests.yml carries no path filter, so this is not configuration drift. action_required means GitHub is waiting for a maintainer to approve the runs. #88 therefore needs a workflow run approval before a review approval; its checks have not started, so it does not belong in any "checks clean" grouping. Correcting the CHANGELOG entry, which repeated the mistake made earlier with #90: recording something as a gap with no PR when a PR already carries it. #88 adds CHANGELOG.md in Keep a Changelog form with an [Unreleased] section. The gap is main not having it yet, which is a wait on #88, and #88 is its single writer. Do not open a second CHANGELOG PR. Release readiness is unchanged: still blocked by cryptography==49.0.0 on main, and the release row now points at #88 for the changelog rather than claiming none exists. Docs only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
Sharpens the blocker analysis with review history, and corrects an overgeneralisation in the previous commit. The approval mechanism is not absent, it regressed on a datable day. opencode-agent[bot] approved 18 times across 10 merged PRs between 2026-07-11 and 2026-08-13, and has approved nothing since. Reviewing did not stop: 20 of 36 open PRs carry opencode-agent reviews, with verdicts running to 2026-08-24, but every verdict since 08-13 is CHANGES_REQUESTED or DISMISSED. The reviewer keeps judging and never approves, which is why waiting does not clear the queue. That also means "needs an approval" is not a uniform diagnosis, so the per-PR table now distinguishes three cases: - CHANGES_REQUESTED on the current head (#32, #27, #64): the requested changes are the work, not an approval. - A stale verdict on a superseded head (#51 dismissed, #35 changes requested, #73 changes requested): different handling, and whether a stale request still blocks depends on the repository's stale review dismissal setting. - No review at all (#59). Records that the branch protection settings cannot be read with the current token (Resource not accessible by integration), so whether #35's stale request blocks has to be confirmed by someone who can see them, rather than asserted here. #51 and #59 remain the two PRs that are check-clean with nothing blocking on the current head. Docs only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
#73 advanced from 311668e to 1681a7f on 2026-09-09 and its new head changes requirements.txt cryptography==49.0.0 to 50.0.1. The previous record described this as a two-way duplicate between #81 and #57; it is three-way, and the targets disagree. #81 ce40bd8 50.0.0 Draft #57 fb48fc9 50.0.0 mis-titled, checks clean #73 1681a7f 50.0.1 non-draft, four failing checks Whichever lands first leaves a requirements.txt conflict for the other two, and if a 50.0.0 head lands first #73 immediately becomes a re-bump. Choosing the version now precedes choosing the writer, and both are owner decisions; this records only that three branches exist. Also refreshes #81's row: it now fails opencode-review on its current head, and its note points at all three writers instead of one. Verified on settled heads with no pending checks, since #73's head moved twice during observation and an in-flight read showed a check set that no longer existed. Docs only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
Tracing why #81 stays Draft surfaced three evidence defects in this document itself. All corrections are pinned to a reviews-API response or a job log, not to a label or a PR body. 1. Merge order item 1 cited "OpenCode exact-head APPROVE (comment 5469292683)" as the squash condition for #81. That comment is seonghobae asking @opencode-agent to approve, not a verdict. GET /pulls/81/reviews returns zero opencode-agent[bot] reviews at any commit -- no APPROVED and no CHANGES_REQUESTED. 2. Merge order item 3 called #58's head-matching review a previous APPROVE. Its API state is DISMISSED; only the review body prose says "Result: APPROVE", and the SHA that body claims does not match the review's own commit_id. 3. The headline recommendation -- "#51, #35, #59 only need approval, no failing checks" -- is retracted. Their green opencode-review is a no-op generation whose entire job body is one echo line (jobs 95467498879 / 95715500577 / 95568388849, 3-4s). The fail-closed generation that actually queries the reviews API is what fails on #81 (job 99291035386, 10s). Their checks are also three weeks stale; #79 re-scanned on 2026-09-01 and trivy-fs failed on CVE-2026-69247 requirements.txt:125 cryptography (job 99954799788). This inverts the priority the document has been giving: #81 is the only PR whose review gate actually ran, and #35/#51/#59 are PRs whose gate never ran. Also refreshes #73's head 311668e -> 1681a7f in the two places still carrying the old SHA, and records that its current head holds a CHANGES_REQUESTED -- requested changes, not a pending approval. Adds a verdict-reading rule (state + commit_id from the reviews API; never a request comment, never body prose, never a 3-4s green) and records that the opencode-review dispatch half is owned by ContextualWisdomLab/.github (#2040/#2051/#2056), so the portal waits for that repair instead of working around it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
Follow-up to 87300ca. That commit said resolving the current-head CHANGES_REQUESTED on #32/#73 was independent portal work. Reading the review bodies and the job logs shows it is not, so this corrects it. Neither verdict carries a code finding. Both are the same sentence -- "OpenCode could not approve from deterministic current-head evidence because GitHub Checks have failed" -- with one item: fix the failed checks. Unresolved review threads are zero on both (#73 31 of 31 resolved, #32 3 of 3). #32's two verdicts are the same head and body posted 87 minutes apart, so they are one blocker, not two. Following each failing check to its log gives the owner map: opencode-review (#81) -> .github #2040/#2051/#2056 verification half fails closed waiting on a dispatch verdict CodeQL compatibility (#73)-> .github #2040/#2051/#2056 VERDICT_STATE pending; the wake that should rerun the job never arrives, though the dispatch itself reports success noema-review (#73, #79) -> contextual-orchestrator #971 trivy-fs (#32, #79, ...) -> this repo, via #81 The noema-review case is a routing defect with its own evidence in the job: preflight probed 16 candidates and separated 4 ready from 4 deferred, then the call selected openrouter dots-3-note-preview:free -- which that same preflight had just marked deferred with HTTP 429 -- and died on 429 after 366.4s. The job's own warning says the gateway owns repair and failover, so this is not a caller-side retry. So the portal owns exactly one failing check, trivy-fs, whose single writer is #81. The rest is upstream control plane. Recording that the queue is upstream-blocked is more accurate than inventing portal work that does not exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
The merge order in this document rests, in every item, on receiving a current-head opencode-agent APPROVE. Before rewriting that order I checked whether the rest of the org waits for the same receipt. It does not. This repository's protected main has had no merge since #66 on 2026-08-17. #83 merged on 2026-08-26 but into #73's branch, not main. That is 23 days, 35 open PRs, and 22 merges in the repository's whole history. The org is not stalled over the same window: 7,454 merges total, several on 2026-09-09 itself. The stall is local to this repo. Sampling the most recent merge in six in-scope repositories, none of the six carried an opencode-agent APPROVED verdict on the head that merged, and four had no reviews at all. The clearest case is the governance owner itself: .github #2028 merged at af7c6e3 while its only opencode-agent verdict was CHANGES_REQUESTED bound to 1d9c70b. Recorded with its limits: base refs were not checked, so some of those may be stack merges into a PR branch rather than protected main, and the sample is one PR per repo across six repos. This is written as a decision for the maintainer, not an action. Either the rest of the org under-applies the gate or this repo is waiting on a receipt that will not arrive, and neither is an agent's call. Lowering the gate, self-approving, and bypassing protection stay forbidden here exactly as everywhere else in this document. What the measurement adds is the cost of the wait: main has carried HIGH CVE-2026-69247 for 23 days, with 35 PRs queued behind it, and that is urgent independent of how the gate question is settled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
c5c1ad6 attributed the noema-review gateway failure to contextual-orchestrator#971. That PR is the broad routing and selection lane; the specific owner record is issue #1106, "own free-pool admission and remove leaf heuristic preflight", which already carries three 2026-09-08 instances of the same failure shape. Filed the 2026-09-09 semantic-data-portal instance there as a fourth: ContextualWisdomLab/contextual-orchestrator#1106 (comment 5601096976) It adds one fact the earlier three do not state. The served route was one the same run's preflight had already marked deferred with 429, while four routes in that same report were marked ready. Per #1106 the constant is REVIEW_PREFLIGHT_DEFERRED_PRIORITY_PENALTY = 1000 -- a ranking penalty, not an exclusion -- so a deferred 429 route stays eligible and can still win selection. Evidence only. No portal-side retry, fallback, or workaround is added here, and the owner's fail-closed direction is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
Explains the one thing the earlier commits left open: why #81 alone has zero opencode-agent reviews while #73, in the same repository, received a CHANGES_REQUESTED on 2026-09-09. It is a closed loop, and the source is in the central scheduler. The review verdict is produced by a repository_dispatch that the PR Review Merge Scheduler sends -- ContextualWisdomLab/.github, in scripts/ci/pr_review_merge_scheduler.py:2205, event_type "opencode-review". The repo-side opencode-review check only reads the result back. That same script's per-PR decision function drops drafts as its first action, at :2383: if pr.get("isDraft"): return Decision(number, "skip", "draft PR") The return precedes cancel_stale_pr_runs, the base_ref branch, and every path that reaches the dispatch. A draft PR is therefore never dispatched for review. #81's body requires, as promotion item 4, a current non-author approval before it may leave Draft. So: draft -> scheduler skips -> no dispatch -> no verdict -> criterion 4 unmet -> stays draft. The mention path is not a working substitute right now. agent-mention-opencode-dispatch.yml has no draft filter, but the four @opencode-agent approval requests on #81 (2026-08-25 through 08-31) all produced nothing, and .github#2058 -- routing review-agent mentions natively -- is open as of today. The release is a single human action: return #81 to Ready for review. Not done here. Draft was a deliberate choice by the PR author, recorded with explicit promotion criteria in the PR body, and reversing another author's governance decision is the kind of act the rest of this document forbids. The deadlock and its release point are recorded; the decision stays with the maintainer. Also notes, from the same function's non-draft branch, that stacked PRs whose base is a feature branch get no org required workflows and are dispatched by the scheduler instead. That is why #102 carries only the two fuzz checks -- structure, not a defect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
.github#2045 owns review-job materialization across the Draft boundary, so that is where the #81 deadlock belongs. Its two canaries (html4tree#600, wardnet#130) are the opposite direction though: both crossed Draft to Ready and kept a stale GREEN. #81 never crosses, and its review check is correctly RED rather than falsely green. Filed it there as a third canary: ContextualWisdomLab/.github#2045 (comment 5601146614) The note also points at repair-contract item 2, which reads "the Ready transition or bounded central reconciliation materializes fresh check identities". For a PR whose transition is itself blocked, the first half cannot help, so either the reconciliation half has to reach a still-draft PR or the contract has to say plainly that a verdict is never a precondition for leaving Draft. Either is fine; today the state is silent rather than typed, which is why it reads from outside as an ordinary review wait. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
…ilable The header still said to walk the merge order. Item 1 is not executable by a steward -- #81 cannot obtain the verdict its own promotion criteria require while it stays Draft -- so following that instruction produces no progress and reads as an ordinary review wait. States the single available action instead: return #81 to Ready, which is what lets the scheduler stop skipping it and a verdict exist at all. Everything from item 2 down only becomes meaningful after that. The 2026-09-07 status note under item 1 already said the action was to finish #81's repair and flip it to Ready. What this session adds is that the flip is not optional sequencing but the release of a closed loop: no approval can arrive first, in either direction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
…e verdicts A full read of #73's 38 current-head check runs at `1681a7f` shows four failures, and `trivy-fs` is not among them. It and `strix` are green. This baseline recorded #73 as "trivy-fs inherit + strix fail" in both the merge order and the PR table. That was measured against an older head. The current head pins `cryptography==50.0.1` and clears the CVE by itself, so the check the portal actually owns is already green on this PR. That retires a premise stated elsewhere in this document: that `#81` must land before `trivy-fs` unblocks. `#81` is Draft, pinned to `50.0.0`, and cannot obtain the verdict its own promotion needs. `#73` is non-draft, pinned to `50.0.1`, and its pin was observed turning the check green. Which PR carries the repair is an owner decision; this only records that the old premise no longer matches measurement. Also records why the current-head `CHANGES_REQUESTED` carries no code findings: the reviewer's own overview reports `Model pool: exhausted`, so it had no model and fell back to the deterministic check-rollup path. That is the org rule working — fail closed with no paid bypass — and is evidence for the free-pool capacity lane, not a portal defect. The gate job also failed at 09:26:46 while its verdict was published at 09:52, so the check was already fail-closed before the verdict existed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
…ange a grant Self-review of the cap I pushed in 613490f. Putting the length limit inside ``_claim_values`` also capped the value feeding the group -> role lookup, and the role map's **keys** are not capped - so a legitimately long group name stopped matching its own map entry. Measured with ``role_map={"demo": {"g"*300: ["data-analyst"]}}`` and the same 300-character name in the token's ``groups``: granted roles [] (was ["data-analyst"]) Fail-closed, so not a privilege grant - but silent, and ``withheld_group_count`` did not describe it: the group was inside the count cap, only its name was shortened, so the counter read 0 while the role had vanished. Two distinct long names sharing a 242-character prefix also collapsed to the same string and the duplicate guard dropped the second. Matching now uses the real value and only the echoed copy is capped, which is all the amplifier fix ever needed - the response was the thing growing, not the lookup. Re-measured, both properties hold: a 300-char group name in the map -> ["data-analyst"] (restored) two distinct long names -> both matched, both roles granted 100,000 groups -> 17,792 bytes (was 7,578,262) one 2 MB group name -> 1,165 bytes (was 4,000,540) one 2 MB role claim -> 855 bytes The count cap still applies to matching, because per-group work is what an unauthenticated caller could multiply, and that truncation stays declared as ``withheld_group_count``. ``oidc_role_claims`` is a pure echo, so it keeps the length cap at its own emit site. Known and chosen: two names that truncate to the same prefix are echoed identically. Their rows remain distinguishable by ``granted_roles`` and the ``...[truncated]`` marker says the name was cut; disambiguating a 300-character group name in the output would be over-engineering. Reverse-verified: restoring the cap inside ``_claim_values`` fails the two new tests. pytest 690 passed, 11 skipped (was 688) python -m sdp.demo_smoke exit 0, "ready": true Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
``GET /openapi.json`` documented a usable 200 schema on **0 of 60** operations.
Measured by classifying every operation's 200 schema:
54 {"type": "object", "additionalProperties": true} (a bare dict)
4 a bare array
1 {} (empty schema)
1 a bare type
--
0 anything a client could generate a typed model from
Every handler is annotated ``-> dict[str, Any]``, which is true and tells a
consumer nothing: a buyer generating a client from the document got
``Dict[str, Any]`` for the catalog, the evidence pack and every governed read.
The refusal contract and the request bodies were published for the same reason;
this is the third and last of the three.
38 JSON-returning GET operations now publish a schema, and the schemas are
**derived by executing the routes** rather than written by hand - a hand-written
response schema is exactly the drift this product exists to refuse, a published
claim that disagrees with the code producing it.
``tests/test_api_response_contract.py`` fetches every documented operation and
validates the live response against what is published, so drift fails CI rather
than shipping.
That test earned its place immediately by catching a narrowing bug in my own
derivation: ``{}`` ("any") was being absorbed by a concrete type instead of
absorbing it, so a field observed as ``null`` in one dataset and a string in
another published ``string``:
/catalog/datasets: $[1].mappings[1].steward: published string, got None
$[1].mappings[1].approved_at: published string, got None
Derivation rules, chosen so a published schema cannot over-claim: nothing is
``required`` (fields such as ``gap_reason``, ``invalid_configuration`` and the
``withheld_*`` counters appear only in some states); every number is ``number``,
never ``integer``, since ``number`` accepts integers and the superset stays true;
a value observed as ``null``, an empty array's items, and two conflicting
observed types all publish ``{}`` rather than one of them being picked; types
are unioned across the anonymous, ``?user=admin`` and ``?actor=admin`` probes;
nesting is published to depth 5 and a deeper node publishes ``type: object``
with no key claim.
What is not published is declared, not silent: the 20 non-GET operations, whose
schemas would need mutations driven to derive, and whose validating test would
then leave graph-store state behind (that singleton is deliberately outside
``tests/conftest.py``'s restoration). Their request bodies are already
published. A test asserts the declared-unpublished set is accurate - every entry
is a real route, none is also published, and together the two sets cover all 60
operations - so a new route cannot join either set unnoticed.
``GET /metrics`` was documented as ``application/json`` with an empty schema
while serving ``text/plain; version=0.0.4; charset=utf-8``, so a generated
client would have tried to JSON-parse Prometheus exposition text. Corrected to
the content type it actually serves.
``response_model`` is deliberately not used: it filters fields, so it would
change runtime behaviour. This goes through ``responses=`` documentation only,
and ``document_responses`` wraps whatever ``app.openapi`` already is so it
composes with the refusal and request-body wrappers instead of replacing them -
a test asserts both survive, since this is the change that could silently drop
them.
Reverse-verified: narrowing one published ``number`` to ``string`` fails the
live-response check; deleting one route's entry fails three coverage tests.
pytest 735 passed, 11 skipped (was 690)
pytest reversed + 3 shuffles order-independent
pytest tests/fuzz 11 passed
python -m sdp.demo_smoke exit 0, "ready": true
GET /openapi.json 14 $refs, 0 dangling, 60 operations
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
Two residuals an adversarial review of eeb65d4 surfaced. The old metric moved the **wrong way** for the single most useful action. A steward proposing a mapping for the one genuinely uncovered glossary concept is what most improves real coverage; under ``approved / all mappings`` the new proposed row landed in the denominator only, so the published figure fell - measured 0.917 -> 0.846. The metric was anti-correlated with the thing it claimed to measure. Glossary-based coverage already fixes this by construction (verified: 0.8 -> 0.8 on the proposal, and approving it raises it), and a test now pins the direction so it cannot regress. ``/ontology/patches`` was published as this gate's evidence and the measurement never reads the patch queue. The KPI's definition had also promised "or active steward patch proposals", which no code implemented; eeb65d4 corrected that clause to the stricter reading its own guardrail already stated, but left the endpoint in both the gate's ``evidence`` and the KPI's ``source_endpoints``. A diligence reader following the published evidence fetched a queue contributing nothing to the number. Replaced with the surfaces the measurement actually consults: ``/ontology/concepts`` (the denominator), ``/catalog/datasets/{dataset_id}`` (the numerator) and ``/enterprise/evidence-pack`` (where the figure is published). The queue stays genuine evidence for the steward-review surface, which does read it. Checked the general case rather than only the reported one: all 34 endpoint-shaped evidence pointers across the readiness gates, KPIs, controls and production integrations resolve to real routes. Pinned, so a renamed route cannot leave a diligence reader following a 404. Reverse-verified: restoring ``/ontology/patches`` fails both new evidence tests. pytest 738 passed, 11 skipped (was 735) python -m sdp.demo_smoke exit 0, "ready": true Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
The code change landed in 08bd8fe; its CHANGELOG entry did not, because the patch script's anchor assert fired on a line-wrap mismatch and the commit went ahead without it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
… other
Surfaced by an adversarial sweep of the policy/browse/orchestrator path; every
one reproduced at HEAD from my own measurement before being fixed, and each fix
reverse-verified by reverting it.
**Purpose binding was defeated by one space.** ``policy.evaluate`` normalizes
``action`` with ``.strip().lower()`` and compared the other caller-controlled
dimension with ``.lower()`` and no ``.strip()``:
preview purpose='external-export' -> 403 (operator approval required)
preview purpose='EXTERNAL-EXPORT' -> 403
preview purpose=' external-export' -> 200
preview purpose='external-export ' -> 200
query purpose='external-export' -> 403 DENIED
query purpose=' external-export' -> 200 SUCCEEDED
The recorded decision then read ``effect: allow``, so the compliance ledger
affirmatively certified the export as policy-approved - worse than the bypass,
because that row is what an auditor trusts. And because the *casing* variant was
blocked, nothing told an integrator the field was whitespace-sensitive.
The row-filter obligation had the mirror-image bug on the same field: it
compared ``purpose == "analysis"`` exactly, so the only row filter the engine
emits silently vanished for ``'Analysis'`` or ``' analysis'`` - no refusal, just
a missing obligation. One normalization at the entry point fixes both and keeps
the two branches from drifting apart again.
**GET /ontology/term/{term}/graph served every tenant's dataset ids to an
unauthenticated caller**, with zero policy decisions and zero audit events,
while the two siblings serving the same fact refuse it
(``/ontology/concept/{concept}`` returns ``dataset_ids: []`` with
``withheld_dataset_count: 4``; ``POST /llm/search`` answers 403). This is the
*third* leak of this payload: the first two were closed by giving
``ontology.concept_assets`` a tenant scope, and this route reads the graph
store's own ``concept_graph`` instead. It contradicts the guardrail the product
publishes about itself ("No unauthorized dataset existence disclosure") and the
governance invariant that a governed read evaluates policy and records evidence.
Scoping lives in ``policy.scope_concept_graph`` so the store keeps no policy
opinion, and the shared vocabulary - definition, aliases - stays open, because it
is dataset identity that is tenant data. Fixing one caller at a time is what
produced three instances, so a test enumerates the routes whose published 200
schema declares ``dataset_ids`` and asserts none of them hands a foreign id to
an anonymous caller.
**The graph write boundary failed open.** ``_authorize_graph_write`` evaluated
``create`` with no ``target_tenant_id``, and ``create`` is the one branch that
cannot resolve the owning tenant from the catalog, so it skipped the tenant
check entirely - the role check ran, the boundary did not:
READ /graph/nodes/zeta-secret:customer_email -> 404 node not found
WRITE /graph/nodes {... properties: {pii: false}} -> 200 upserted
platform-admin read, before -> {'datatype': 'string', 'pii': True}
platform-admin read, after -> {'pii': False, 'datatype': 'string'}
A foreign admin could flip the ``pii`` classification of a column it cannot
read, on metadata that feeds ``POST /search/semantic`` and ``POST /graph/query``.
An edge touches two nodes and only its source was checked, so both endpoints are
checked now, and the denial is audited.
One residual is declared rather than implied: refusing a foreign node while
allowing one whose dataset does not exist is distinguishable, so an
authenticated **admin** can learn an id names a dataset in a tenant it cannot
access. Collapsing it would mean forbidding the creation of any node whose
prefix is not an accessible dataset, which breaks legitimate non-dataset nodes -
a product decision, not a defect fix. It is strictly narrower than the oracles
this product does close, which were reachable anonymously or by any analyst.
**The preview refusal leaked existence through the ledger.** The HTTP bodies were
already identical; the caller's own ``GET /policy/decisions`` was not - 0 rows
for a foreign id, 1 for an absent one, the row even confirming it was filed into
the prober's tenant. Every sibling dataset-resolving route already routes
refusals through ``policy.evaluate_dataset_scope_refusal``; preview was the one
caller that did not. Now identical on the response and on both ledger channels,
while a platform-admin auditor still sees the refused attempt.
**POST /browse/query returned 500** for any dataset whose ``profile.row_count``
is a JSON string - a value ``POST /catalog/datasets`` stores verbatim, since
``profile`` is ``Dict[str, Any]``. ``_row_count_estimate`` compared it to an int
and raised ``TypeError``, uncaught, so the governed query route was permanently
dead for that dataset. The 500 is the lesser half: the crash lands between the
policy decision and the audit write, producing ``decisions +1 audits +0`` - the
pairing CLAUDE.md declares non-negotiable. Every type is enumerated now, and a
non-numeric value is reported as ``unusable_catalog_profile_row_count`` rather
than labelled an estimate of a catalog profile it is not.
My first version of that guard used ``float(v) != int(v)``, which raises
``ValueError`` on ``NaN`` and ``OverflowError`` on ``Infinity`` - both of which
Python's ``json`` accepts as literals and which I confirmed are reachable
through ``POST /catalog/datasets`` - so it would have been a second uncaught 500
in the same function, and it also misclassified ``10**30``. Enumerating the
values caught it.
Two existing guardrails caught my own mistakes here: the repo's
anti-dangling-evidence test rejected a docstring citing a test file I had not
yet written, and the response-contract test flagged the new
``withheld_dataset_count`` key as schema drift. One of my new tests was
order-dependent (it asserted an absolute withheld count while the graph store is
deliberately not restored between tests); it now derives the expectation from
the platform-admin view.
pytest 797 passed, 11 skipped (was 738)
pytest reversed + 3 shuffles order-independent
pytest tests/fuzz 11 passed
python -m sdp.demo_smoke exit 0, "ready": true
reverting each fix 11 / 5 / 2 / 2 / 12 test failures respectively
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
``validate_sql_query`` is the documented boundary a real engine would sit
behind, so a statement it certifies with zero warnings is a claim about what
that engine will do. Four ways the claim was false or self-contradictory, all
measured against an allow-list of ``customer``.
**An identifier-continuation character could shorten a table name.** The token
``[A-Za-z_][\w.]*`` stops at ``$`` and at every byte from 0x80, so it split one
identifier into two and discarded the remainder as an alias:
SELECT count(*) FROM customer$pii_raw LIMIT 1 -> [] , parsed ['customer']
SELECT count(*) FROM customer×pii_raw LIMIT 1 -> [] , parsed ['customer']
SELECT count(*) FROM secret$customer -> ['unauthorized_table_reference']
PostgreSQL's identifier continuation set is ``[A-Za-z\200-\377_0-9$]``, so the
engine resolves the single table ``customer$pii_raw`` and the allow-list proved
a claim about a name the query does not use - the same unsoundness class as the
comma-FROM bypass this repo rated critical and closed. The token now covers that
set, so the reference parses whole and is compared; a name the allow-list cannot
prove is refused, which is the fail-closed direction. Four legitimate reference
forms are asserted unaffected.
**There was no function allow-list.** The literal guard catches an
argument-bearing call through the string rule
(``pg_read_file($$/etc/passwd$$)`` -> ``literal_values_not_allowed``), which left
zero-argument and integer-argument calls in the safe lane:
SELECT pg_terminate_backend(12345) FROM customer -> []
SELECT lo_unlink(1) FROM customer -> []
SELECT pg_sleep(100000) FROM customer -> []
SELECT pg_reload_conf() FROM customer -> []
docs/prd-trd.md states BRW-005 ("read-only SELECT only ... block DDL/DML and
unsafe functions") as P0 and NFR-SEC-007 repeats it, so this is a declared
requirement rather than an extra. A deny-list is unbounded and so cannot
certify; the allow-list is ``count``, ``sum``, ``avg``, ``min``, ``max`` -
measured from what ``draft_sql`` itself emits across every seeded dataset, plus
the standard read-only aggregates. A parenthesised expression and an ``IN`` list
are not function calls and are asserted unaffected.
Evidence boundary, stated rather than implied: this repository has no live
engine and ``execute_query`` never executes SQL, so what those functions do and
that ``$`` continues an identifier are PostgreSQL's documented behaviour, not
observations. What is measured is the gate's own verdict - and the false
certification is the defect either way.
**The drafter emitted SQL its own validator rejects**, for the third time in
this class. A source table whose name collides with a clause keyword - ``order``
and 23 others - makes the parser leave the table list and return ``None``, which
the validator turns into ``unauthorized_table_reference``:
POST /llm/draft-query -> 200 '... FROM order WHERE order_ts >= ... LIMIT 1000'
POST /browse/query -> 400 {'warnings': ['unauthorized_table_reference']}
In the documented draft-then-execute workflow the product accuses its own output
of a governance violation with no way to proceed, and ``FROM order`` is unquoted
reserved-word SQL PostgreSQL would refuse anyway. It now refuses with an
actionable reason, and - because the two earlier instances of this class (the
``interval '<n> day'`` literal, and this one) were each found by report - the
finished statement is also run through this module's own validator before being
returned, so a fourth cause fails closed instead of reaching a buyer. A test
makes that backstop fire rather than leaving it as untested code.
**A declared column name was silently rewritten and the original reported.**
``order amount`` was emitted as ``order_amount`` while ``requested_columns``
reported ``order amount`` and the assumptions asserted only catalog-validated
columns were used, so the response contradicted itself and a buyer learned
otherwise from the warehouse. The rewrite is kept - a quoted identifier could
never pass this product's own gate, which refuses any statement containing a
double quote, and ``test_draft_query_sanitizes_group_by_identifier`` pins it
deliberately so a hyphen cannot reach the SQL. What changes is that the rewrite
is now reported in ``emitted_identifiers`` and in the assumptions, and that an
**ambiguous** rewrite is refused: the mapping is not injective, so
``order amount`` and ``order_amount`` collapsed to one identifier and a draft
could aggregate a different column than the one asked for.
I first implemented this by refusing the rewrite outright, which failed the
existing sanitization test. That test encodes a real injection rationale, so
overturning it was wrong; refusing only the ambiguous case keeps both concerns.
Reverse-verified by reverting each: 4, 9 and 24 test failures respectively.
pytest 849 passed, 11 skipped (was 797)
pytest reversed + 3 shuffles order-independent
pytest tests/fuzz 11 passed (the Hypothesis mirrors of the
Atheris invariants; atheris itself is the
Linux-x86_64 `fuzz` extra and runs in CI)
python -m sdp.demo_smoke exit 0, "ready": true
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
…empt
``POST /policy/decision`` answers "what would policy say for this subject", so
``subject`` is deliberately caller-supplied. The answer was then persisted into
the compliance ledger unlabelled, attributed to the named subject, with no
record of who asked. Measured as ``analyst`` asking about ``external-analyst``:
POST /policy/decision {"user": "analyst", "subject": "external-analyst",
"resource": "crm-event", "action": "preview"}
-> 200 {"subject": "external-analyst", "effect": "deny",
"reason": "tenant boundary denied"}
rows readable naming external-analyst:
[{"subject": "external-analyst", "resource": "crm-event",
"action": "preview", "effect": "deny", ...}]
An auditor reads a refused preview attempt by a different identity in a
different tenant. No such attempt happened, and the row did not name the caller
who manufactured it. The only way to tell it from a genuine refusal was a
missing audit event - cross-surface correlation an auditor has no reason to
perform.
``subject`` stays caller-supplied; the row gains the two facts that make it
honest - ``evaluation_mode: hypothetical``, ``asked_by``, and
``subject_is_caller``. ``evaluate`` takes an opt-in ``provenance`` mapping that
``_decision`` merges into the recorded obligations, so no other caller's rows
change shape; a test asserts the default stays empty, because threading an
optional parameter through every branch is exactly the change that silently
relabels unrelated evidence.
Reverse-verified: dropping the provenance fails three of the four new tests.
pytest 853 passed, 11 skipped (was 849)
pytest reversed + 2 shuffles order-independent
python -m sdp.demo_smoke exit 0, "ready": true
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
… set
Four defects from a second adversarial sweep, each measured at HEAD first.
**Governance counts were the size of the page they were read from.**
``sdp_request_observations_total`` had this defect and was fixed; the audit and
policy gauges were read the same way and were left behind. Measured with 700
governed mutations in the ledger:
catalog._AUDIT_LOG 700
/metrics sdp_audit_events_observed_total 500
/enterprise/evidence-pack audit_event_count 500
/enterprise/observability audit_events_observed 500
Three published surfaces reported 500 for 700 events, each taking ``len()`` of a
``list_*(limit=500)`` page. A counter that cannot exceed its page is not a
counter, and these are the numbers a procurement reader uses to judge whether
the governance trail is real. ``count_audit_events`` and
``count_policy_decisions`` scope exactly as the paged reads do, and a count that
reaches its ceiling is published as a **lower bound** rather than as the count -
the rule this product already applies to every truncated list.
Fixing the reported surface and leaving the rest is what produced three
instances, so the enumeration is an AST check: a variable assigned from
``list_*(..., limit=N)`` whose ``len()`` is published under a ``*_count`` or
``*_total`` key is the defect shape, and it now fails the suite.
**A half-applied tenant migration was never recovered.**
``_add_tenant_column`` returned before its own backfill once the column
existed. This method shipped in an ALTER-only form first - its own docstring
records that - so every SQLite ledger upgraded by that build already has the
column and would never be backfilled; a process killed between the ALTER and the
UPDATE leaves the same state. Measured on a ledger in exactly that shape, whose
payloads carry a recoverable tenant:
before opening with the current store -> ['unresolved']
after opening with the current store -> ['unresolved']
So the owning tenant's auditor lost every historical row to ``GET /audit/events``
and ``GET /policy/decisions``, permanently, and SQLite is the documented
demo/pilot deployment on a persistent volume - precisely the upgrade-in-place
case. The backfill now runs whether or not the column was just added. It is safe
to re-run: it touches only rows still at the unresolved default, a row with no
recoverable tenant stays unresolved, and three consecutive opens converge.
**A licence could not be set through any API path.** ``Dataset`` has carried
``license`` all along and neither request model exposed it:
POST /catalog/datasets {"license": "CC-BY-4.0"} -> 200, stored "internal"
PATCH .../{id} {"license": "MIT"} -> 200, stored "internal"
every seeded dataset's license -> ["internal"]
A data catalog whose licence cannot be set is missing the field a diligence
reader checks first, and ``metadata_completeness``'s own definition counts
``license`` among its fields - so the number was computed over a field no caller
could populate.
Exposing it was not enough: ``register_dataset`` built the ``Dataset`` from a
hand-written list of ``payload.<field>`` arguments and ignored the new one, so
create still stored the default while patch worked. That constructor now spreads
the request's own fields - exact, not lenient, because
``DatasetCreateRequest``'s fields are a strict subset of ``Dataset``'s, which a
test asserts - and a per-field test drives every create and patch field through
the API and reads it back. A hand-written list is what let a field sit
unreachable, so the replacement for it is mechanical.
**The metadata_completeness KPI's target was never compared.** It published
``target: ">=90 percent display coverage"`` with ``status: implemented`` while
nothing in the code compared anything to 0.90; the only thresholds in the
evidence path were 0.95 on ``metadata_validation_pass_rate`` (the share of
datasets with *no* missing field - a different, stricter measure) and 0.7 on
ontology coverage. A target nothing compares to is not a gate.
``/enterprise/evidence-pack`` now publishes ``mean_metadata_completeness`` and
gates it, and the target names the number it is compared against. An existing
test pinned the old target string; it is updated with the reason rather than
loosened.
Two findings from the same sweep did not reproduce and needed no change: the
``/validate`` vs ``/catalog/datasets/{id}`` completeness disagreement (0.909 vs
1.0) was already closed by the ``REQUIRED_METADATA_FIELDS`` single-source fix -
both now report 1.0 - and ``/ontology/resolve`` returns ``resolved: []`` for a
term the ontology does not define, not the minted URI reported. The residual
there is that a substring match scores 1.0, which is a scoring heuristic and a
product decision rather than a defect.
Reverse-verified by reverting each: 2, 31 and 1 test failures respectively.
pytest 892 passed, 11 skipped (was 853)
pytest reversed + 3 shuffles order-independent
pytest tests/fuzz 11 passed
python -m sdp.demo_smoke exit 0, "ready": true
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
The operator console told a shipped, documented actor that the platform was
broken. Measured with ``?user=external-analyst``, whose tenant does not own the
dataset the connector panel probes:
/enterprise/connectors/sql_connector/probe -> 404 (tenant scope)
/policy/decisions, /audit/events -> 200
evidence, controls, KPIs -> 200
badge -> "evidence load failed: connectors"
Every request was answered correctly - the 404 *is* the tenant boundary working,
and it has to be a 404 because an out-of-scope dataset must be
indistinguishable from an absent one. Two buckets were not enough: a 4xx is the
gate refusing *this* request; only a 5xx, or a status the client could not read
at all, is the platform failing.
My first attempt at the third bucket keyed on ``401 || 403 || 404``, and the
test that forbids a status allow-list caught it - rightly, because that
allow-list is exactly what broke this classifier the first time, when the probe
changed from 403 to 404. The rule is now a range, with no equality against any
individual status, so changing which 4xx a gate returns cannot break it again.
I also rewrote that test. It pinned the exact ternary
``(!viewer && !serverError ? needActor : failures)``, which is one expression
rather than the rule, and a correct three-branch implementation could not
satisfy it. The assertions now check the rule - key on actor presence, never on
a specific status - plus the new branch and the probe's actual status measured
in the same file, since the original regression happened because two tests in
one file disagreed about that status and both passed.
The panel hint is per bucket too: telling a viewer who *did* supply an actor
that the panel "needs an actor" is a second wrong answer.
Verified in Chromium against a live server:
signed out badge "signed-out view - add ?user= to load: connectors"
panel "이 패널은 actor가 필요합니다 (?user=)"
external-analyst badge "refused for this actor: connectors"
panel "이 actor에게는 거부된 패널입니다 (tenant 범위 밖이거나 권한 없음)"
analyst / admin badge "ready for pilot review", real connector rows
Also corrected an evidence claim in d3ca845. That commit recorded the
PostgreSQL half of the identifier-continuation finding as documented behaviour
rather than observation; an independent verification of the same finding stood
up a throwaway UTF-8 cluster and measured it - ``customer``,
``customer$pii_raw`` and ``customer×pii_raw`` exist as three distinct tables,
and ``SELECT count(*), max(secret) FROM customer$pii_raw`` returns the planted
row while ``SELECT count(*) FROM customer`` returns 0. The claim was understated,
not wrong, and the comment and changelog now say what was measured.
pytest 894 passed, 11 skipped (was 892)
pytest reversed + 2 shuffles order-independent
python -m sdp.demo_smoke exit 0, "ready": true
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
It said "164개 커밋 중 112개" - the values at the time it was written, not
reflecting the commits added since. Measured:
git rev-list --count origin/docs/product-technical-gap-baseline..HEAD -> 186
gap-baseline documentation commits among them -> 113
A stale number in my own changelog is the same defect class as the published
counts this branch spent the day correcting, so it is measured rather than
re-estimated, and the note says where the number comes from.
The rest of the evidence block was re-verified against the suite and matches
exactly: 894 passed / 11 skipped, fuzz 11 passed, demo_smoke exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
The group-count cap applies to matching, including on the verified OIDC route, so it can change an authorization outcome. 78d51b0 called that fail-closed and asserted it only in prose and a comment - a security property stated rather than measured. Measured now, enumerated over the position of the privileged group in the claim, because a cap that truncates a list is exactly where ordering could matter: admins first uncapped 401 roles | capped 256 | gained none admins last uncapped 401 roles | capped 256 | gained none admins at 300 uncapped 401 roles | capped 256 | gained none Two of the three lose platform-admin - withheld, the safe direction - and no case gains a role the uncapped evaluation did not grant. Pinned, so a future change to the cap cannot quietly turn a truncation into a grant. pytest 895 passed, 11 skipped Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
…orced
`CLAUDE.md` names `docs/implementation-compliance.md` the requirements
correspondence matrix. Measured against the PRD it is meant to cover:
requirement ids in docs/prd-trd.md 74 (52 functional + 22 NFR)
ids mentioned anywhere in that matrix 13
P0 ids absent from it 25 of 34
Most of the absent ones are in fact built, which makes it worse rather than
better: a reader cannot tell which, and absence reads as absence. A
hand-maintained matrix rots in exactly this direction.
`docs/requirements-coverage.md` is generated instead. The inventory is parsed
from the PRD, so it cannot drift from the source, and every id must carry an
explicit status (implemented / partial / not_implemented / external) plus
evidence pointers. `tests/test_requirements_coverage.py` enforces: every PRD id
is mapped; no mapping survives for an id the PRD dropped; every pointer resolves
against a served route, an importable attribute, or a test id pytest actually
collects; an `implemented` claim cites a collected test; a weaker status explains
itself; and the committed document equals the generated one byte for byte. Ten
corruptions were reverse-verified to fail - including a new PRD requirement,
which breaks the suite until it is answered.
The mechanism caught two defects in this change itself. 25 of the 102 evidence
pointers I wrote from memory did not resolve (one function name that does not
exist, 24 test ids pytest does not collect) - a hand-written citation is wrong
the moment it is written. And the published vocabulary defines `implemented` as
answered *and* covered by a test, while the gate accepted a route pointer alone;
CAT-009 was in that state, with two real routes, two real tests and neither
cited. The gate now requires the `test:` pointer so the word and the check agree.
CAT-001 drops from implemented to partial. Its acceptance criterion is "95p
within 2 seconds, facet counts included" and neither half holds: the search
response is {query, count, items} with no facet counts (facets are a separate
route), and nothing measures p95 against the 2-second budget. The one latency
figure this repository does record runs against it - src/sdp/catalog.py:468
measures 5.10s for a 2000-token query over 1005 datasets, which is why the input
is bounded rather than optimised.
Verification (local CI parity; repository CI cannot run - account Actions
billing lock rejects every job pre-dispatch):
pytest 909 passed, 11 skipped
pytest, reversed + seeds 11/707/31337 order-independent
pytest tests/fuzz 11 passed
python -m sdp.demo_smoke exit 0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
`docs/prd-trd.md` declares a p95 budget per surface - NFR-PERF-001..006: catalog
keyword search 2s, dataset detail 1.5s, schema browsing 3s, preview 10s, LLM
catalog answer 15s, ontology term resolution 500ms. None of them related to any
code in this repository. The middleware has recorded `latency_ms` per request
all along and `/enterprise/observability` published it, but nothing named a
budget, so the six declared targets had nothing to compare against - and a
target nothing compares to is not a gate. CAT-001's acceptance criterion names
the same 2-second figure and was in the same state.
`GET /enterprise/latency-budgets` binds each budget to the routes it governs and
compares it to a measured nearest-rank p95. Route classification uses the app's
own compiled patterns, so it cannot disagree with the dispatch that produced the
observation.
The measurement is deliberately one-sided. The observations come from this
process: in-memory stores, seed data, one worker, no client-to-app network. A
deployment adds a database, a real dataset population, concurrency and network
hops, none of which make a request faster. So an in-process p95 is a lower bound
on the deployed p95:
- above budget refutes the budget - if it is already over here, it is over
there;
- below budget establishes nothing about the deployment.
The published field is therefore `budget_refuted`, not `within_budget`, with
`budget_confirmation_possible: false` and the reason attached. A field named
`within_budget: true` reads as "the SLO is met", which this measurement cannot
support in the direction that matters.
Two further fail-closed rules. A surface under 20 observations reports
`insufficient_sample` and produces no verdict in *either* direction - a p95 over
a handful of requests is the sample maximum wearing a percentile's name.
Refusals do not count: a refusal is not a search, and fast 4xx responses would
pull every p95 down by exactly the requests that did the least work. Both are
published, because which requests were measured changes what the number means.
Only route templates are published, never concrete paths - the same disclosure
boundary for which the unauthenticated manifest already withholds the
per-request feed (a concrete path embeds the dataset id).
The tests drive real traffic at every governed surface, require all six to
evaluate, and assert none is refuted. They also prove the refutation path fires,
by recording observations slower than the tightest budget and watching the
verdict flip: without that, every `budget_refuted: false` could be a comparison
that never happens. The kind of defect this gate catches is already recorded
here - src/sdp/catalog.py:468 measures 5.10s for a 2000-token query over 1005
datasets, over NFR-PERF-001's 2-second budget, which is why the query length is
bounded rather than optimised.
NFR-PERF-001..006 move from not_implemented to partial, and CAT-001's note now
records that its latency half is measured. Not `implemented`, for the reason
above: the requirement is a statement about a deployment and this measurement
cannot confirm one.
The response contract caught both the new route and the changed observability
body and was regenerated from executed responses (39 published GETs, was 38).
Verification (local CI parity; repository CI cannot run - account Actions
billing lock rejects every job pre-dispatch):
pytest 924 passed, 11 skipped
pytest, reversed + seeds 11/707/31337 913 passed each, order-independent
pytest tests/fuzz 11 passed
python -m sdp.demo_smoke exit 0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
…N-004)
LIN-004 (P1) requires a warning on a dataset's detail when a quality threshold
is violated, carrying rule, observed value and threshold. The ingredients were
all present and all unused: `quality_score`, `freshness_score`,
`metadata_completeness` and per-column `nullable_ratio` / `distinct_ratio` /
`quality_issues` were carried, stored and published - but no threshold was
declared anywhere, so nothing could be violated and no warning could exist. A
number with no threshold is not a quality signal; it is a number.
Six rules now compare those inputs to declared thresholds, and each violation
carries exactly the three fields the acceptance criterion names. Three
properties hold it honest:
A rule that cannot be evaluated does not pass. An absent input, a non-number, a
NaN or infinity, or a bool (an `int` subclass, so `True` would otherwise compare
as 1.0) reports `unevaluated` with the reason, and is counted. Treating missing
data as "within threshold" turns absent data into a clean bill of health, which
is the opposite of what a quality surface is for.
A warning never crosses the discovery boundary. `catalog.discovery_view`
withholds the per-column schema and the profile from the actor-less surface, so
a warning reading "column ssn is 94% null" would route exactly that withheld
statistic through the quality field. Rules are scoped: dataset rules read fields
discovery already publishes and appear there; column rules appear only behind
the policy gate, and the open surface publishes
`withheld_quality_warning_count` so a short list cannot read as a clean dataset.
Thresholds are one list. `metadata_completeness` reuses the 0.90 the saleability
gate already compares against instead of restating it - two copies drift, and
then a dataset is simultaneously a pass and a gap. A test pins them together.
The thresholds are product-wide defaults, not per-tenant or per-domain
configuration; LIN-004 does not ask for configurability and
`threshold_source: product_default` says so on every warning.
The tests build a violating fixture for each of the six rules and assert it
fires, because the seeded demo data is clean against every threshold - a test
that only read the seed would pass against a rule set that never triggers. They
also assert a value exactly at the threshold does not fire (the seed's
`semantic-glossary.broader_concept` sits at exactly the null-ratio ceiling) and
that 0.001 past it does, so the first assertion cannot pass by way of a dead
rule.
The pinned discovery key set and the response contract both caught the change,
which is what they are for; the key set gained one explicit entry and the
contract was regenerated from executed responses.
LIN-004 moves from not_implemented to implemented.
Verification (local CI parity; repository CI cannot run - account Actions
billing lock rejects every job pre-dispatch):
pytest 937 passed, 11 skipped
pytest, reversed + seeds 11/707/31337 926 passed each, order-independent
pytest tests/fuzz 11 passed
python -m sdp.demo_smoke exit 0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
…e invariant
Two defects found reviewing the two features just added.
**The observability manifest got expensive, on a scrape path.** One
latency-budget report classifies up to the observation buffer's capacity of
paths against every route, and `prometheus_metrics_text` reaches that report
through `build_observability_manifest` - so every Prometheus scrape paid for
500 x ~60 pattern matches. Measured with a full buffer of 500 distinct paths:
GET /metrics 37.0 ms -> 5.8 ms per request
GET /enterprise/observability 42.7 ms -> 7.3 ms
GET /enterprise/latency-budgets 73.6 ms -> 6.2 ms
Memoised, because real traffic repeats a small set of paths. The cache is
bounded and a test asserts the bound: a `{dataset_id}` path is caller-supplied,
so an unbounded map keyed on it is a way to grow process memory from outside -
the same reason every retained collection here is capped. The route table is
fixed at import and nothing registers a route later, so a cached classification
cannot go stale. The classification itself still uses the app's own compiled
patterns, so it cannot disagree with the dispatch that produced the observation.
**An `assert` guarded a comparison inside a published read.** Every quality rule
but `non_empty` declares a threshold, so it is an internal invariant - but
`assert` is stripped under `-O`, and a `None` threshold would then have reached
`numeric < None` and raised TypeError inside `GET /catalog/datasets/{id}`. A
misdeclared rule now reports itself as `unevaluated` with the reason, which is
what every other uncomparable input already does, and a test covers both that
branch and the fact that no shipped rule is in that state.
Verification (local CI parity; repository CI cannot run - account Actions
billing lock rejects every job pre-dispatch):
pytest 939 passed, 11 skipped
pytest tests/fuzz 11 passed
python -m sdp.demo_smoke exit 0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
…over
The observation buffer is one 500-entry ring across *all* routes, so a busy
surface evicts a quiet one's rows. A surface sitting at exactly its minimum
sample may therefore be publishing a p95 over twenty requests spread across
days, directly beside a busy surface's p95 over twenty requests from the last
minute - and the report gave a reader no way to tell them apart. Two numbers
that look like the same kind of measurement were not.
Each surface now publishes `sample_window_seconds`: the wall-clock span its
counted observations cover. The width of the window is what a reader needs and
could not otherwise obtain.
It is a span, in seconds, and not a pair of timestamps. A reader needs to know
the window is 40 seconds or six days; they do not need to know the hour at which
someone read a governed dataset, which is precisely what the unauthenticated
observability manifest withholds the per-request feed to avoid saying. A test
asserts no calendar date reaches the report.
Three ways a stamp can be unusable are handled rather than left to raise inside
a published read: absent or non-string, unparseable, and a mix of aware and
naive stamps (subtracting one from the other raises). In each case the latencies
still count and only the span reports as unavailable.
Also removed a vacuous assertion I had just written in the same test file - an
`assert ... or True` that could never fail. It is now a real check that no
`\d{4}-\d{2}-\d{2}` appears anywhere in the report.
Verification (local CI parity; repository CI cannot run - account Actions
billing lock rejects every job pre-dispatch, re-confirmed on head ae5af74:
3-second jobs with the verbatim "account is locked due to a billing issue"
annotation):
pytest 943 passed, 11 skipped
pytest tests/fuzz 11 passed
python -m sdp.demo_smoke exit 0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
A fourth adversarial sweep (8 agents, every finding independently reproduced)
returned 15 confirmed defects. These are its four `high` ones. FastAPI runs this
package's `def` handlers in a worker threadpool, so two requests genuinely
interleave, and nothing serialised the read-modify-writes over the module dicts.
**Concurrent PATCH lost a write, and the audit ledger certified the loss as
success.** Measured by forcing the interleaving - pausing one writer between its
read and its write:
PATCH description=FIRST -> 200
PATCH steward=SECOND -> 200
stored: description=FIRST, steward=<unchanged>, version=1.0.1
audit: success {"description": "FIRST"}
audit: success {"steward": "SECOND"}
The lost write is bad; the audit row is worse. This product's stated governance
invariant is that a decision and an audit event accompany every governed write,
and here the ledger certified a change that is not in the store - the event is
built from the writer's own `updates` and never re-read from `_DATA`, so it
cannot notice. Unsynchronised, 50 concurrent patches advanced the version by 25.
A reentrant mutation lock in `catalog` now wraps all four mutations.
**Concurrent creates handed one generated id to several callers and silently
destroyed datasets.** Eight id-less creates each returned 200 and the catalog
grew by five; one id went to three callers. `len(_DATA) + 1`, the `in _DATA`
check and the store write were three separate steps. Under the lock, the id is
now the first free one - a count never answered "which ids are free", which also
made it wrong sequentially once a caller registers a `dataset-NNN` id of its own.
**The catalog and the graph disagreed permanently.** Two concurrent schema
patches applied to the catalog in one order and to the graph in the opposite one
(11 of 30 trials): the graph kept a `pii: true` column the catalog does not
declare, `/search/semantic` returned it as the top hit, and
`/browse/{id}/schema` could not mask it, because for the catalog that column
does not exist. `api` now holds the mutation lock across the mutation *and* its
mirror.
**A mutation deleted lineage edges its partner declared.** `A -> B` is declared
by A's `lineage_outputs` *or* by B's `lineage_inputs`, and the mirror creates it
from either side, but the reconcile consulted only the mutated dataset's half.
Seeding or mutating the non-declaring endpoint deleted an edge the catalog still
declared - and the startup bootstrap seeds every dataset in turn, so whichever
was seeded second wiped the other's declaration. An edge is now removed only
when neither endpoint declares it.
**`/search/semantic` returned 500 during onboarding** - `_embeddings` iterated
while it grew, 17 of 42 searches (40%) with two readers and two writers. The fix
is a lock in the store, not a snapshot at the reported line: `_edges` is rebuilt
wholesale by `delete_edge`/`delete_node` while `edges_for`/`traverse` iterate
it, and `delete_node` mutates three collections in sequence, so a reader could
see a node whose embedding was already gone.
The regression tests force the interleaving rather than wait for a race - a
pause between a writer's read and its write, never a barrier, which inside the
critical section would deadlock against the lock it verifies. Each repair was
reverse-verified by replacing its lock with a no-op and watching the matching
tests fail. `test_an_edge_read_survives_edges_being_rebuilt` records in its own
docstring that it passes without the lock too: rebuilding a list by assignment
does not raise in CPython, so it is hardening, not a reproduction.
One retained test had to change. `test_every_mirrored_facet_is_reconciled_not_
only_appended` cleared `crm-event`'s lineage lists and asserted the edges were
gone, but the seed declares `crm-event.lineage_outputs` and
`crm-customer-master.lineage_inputs` for the *same* edge - so reconciling it
away on one side's say-so was the bug. Its two lineage parameters moved to a
test of the two-sided rule; its three single-declarer facets are unchanged.
Also in this commit, because it touches the same files: LIN-003's acceptance
criterion - metric timestamp and computation scope on a profile figure. The
route published `row_count: 1200000` and `null_ratio: 0.01` and neither, so a
declaration read as a measurement. Nothing here computes any of it; there is no
profiler, so no null ratio can be counted. The figures stay, labelled:
`computed_by_this_service: false`, the declared timestamp or `null` (never the
read time, which would make a stale declaration look fresh per request), and
`declared_unavailable_metrics` naming `duplicate_ratio` and `distinct_count`
with reasons. `distinct_count` is deliberately not derived from
`distinct_ratio * row_count` - both are declarations, and multiplying them
manufactures a measurement neither supports.
CAT-005 and LIN-001 drop to partial (no before/after diff; no source/job/target
node). NFR-SEC-005 stays implemented with a note that the ledger was 100%
complete and partly false. LIN-003 stays partial with its gaps named.
Verification (local CI parity; repository CI cannot run - account Actions
billing lock rejects every job pre-dispatch):
pytest 965 passed, 11 skipped
pytest, reversed + seeds 11/707/31337 954 passed each, order-independent
pytest tests/fuzz 11 passed
python -m sdp.demo_smoke exit 0
concurrent id-less creates 4/4 batches, 0 datasets destroyed
semantic search under onboarding 152 searches, 0 errors
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
…uite
`requirements-dev.txt` is the artifact README and CLAUDE.md tell a developer to
install, and `PYTHONPATH=src pytest` is the command they tell them to run next.
Measured in a clean venv:
pip install --require-hashes -r requirements-dev.txt -> ok
PYTHONPATH=src pytest
-> ERROR tests/fuzz/test_fuzz_properties.py
Interrupted: 1 error during collection
0 tests executed
The lock was missing `hypothesis`, `sortedcontainers`, `sqlalchemy` and
`greenlet`, although pyproject.toml's `dev` extra declares the first and third
and the file's own header claimed it had been compiled from that extra. CI never
noticed: it installs `requirements-test.txt`, which pins all four. After the fix
the same clean venv runs 968 passed, 14 skipped.
The four are added at the versions CI pins, not at a free resolution. Resolving
the dev extra freely picks `hypothesis==6.169.1` and `sqlalchemy==2.1.4` where
CI pins `6.156.6` and `2.0.51`, and SQLAlchemy is what `src/sdp/graph_store.py`
and `src/sdp/config.py` build on - a minor-version gap there is how a green
local run hides a red CI. `constraints-dev.txt` holds exactly those four, so no
other pin moves; that is also why the pip-compile-generated Docker lock
(`requirements-graph.txt`) stays in agreement with `requirements.txt`.
`tests/test_dependency_locks.py` now enforces: every package the dev extra
declares is pinned, the suite's imports are installable, dev and CI agree on
every shared pin, and each lock's header is a runnable command that regenerates
it. The agreement test carries one declared allowance - `tzdata` - with its
reason: forcing global agreement means regenerating the pip-compile Docker lock
too, whose own constraint pins `greenlet==3.2.5`, so a blanket invariant would
be a gate this repository does not meet. A separate test fails if an allowance
stops being real, so it cannot become cover.
Three documentation defects from the same sweep:
- README's only local-setup block could not run in any shell: a PowerShell
activation path inside a ```bash fence, followed by POSIX-only
`VAR=value command` syntax.
- Two `docs/enterprise-readiness.md` commands omitted `PYTHONPATH=src`. The
documented install does not install the project, so `uvicorn sdp.api:app`
fails with `ModuleNotFoundError: No module named 'sdp'` (verified).
- README and CLAUDE.md said the container exposes a `/health` healthcheck;
the Dockerfile and compose file probe `/healthz`, and README's own later
paragraph explains that distinction - so the document contradicted itself.
One reported defect did not reproduce and was left alone: the sweep called
AGENTS.md's `pip install -e .[dev]` the same omission, but an editable install
genuinely works and makes `sdp` importable without `PYTHONPATH`.
`requirements.txt` is deliberately untouched. Its header names a plain compile
and its contents carry no platform markers, so nothing here establishes that it
was compiled universally; regenerating it would have bumped pins for no reason.
Verification (local CI parity; repository CI cannot run - account Actions
billing lock rejects every job pre-dispatch):
pytest 971 passed, 11 skipped
clean venv, documented install + command 968 passed, 14 skipped
same venv before this change collection error, 0 tests
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
Three defects from the same adversarial sweep, sharing one shape: a request that
changed nothing was recorded as though it had, so the ledger, the version
sequence and `updated_at` all described work that did not happen.
`publish_dataset` already modelled the answer - it guards its already-published
case and records the attempt as a no-op - and the other two paths did not.
Measured on `crm-customer-master`:
POST .../deprecate x3 -> 200, 200, 200
version 1.0.1 -> 1.0.2 -> 1.0.3
3 "dataset.deprecate / success" ledger rows
3 state snapshots appended
PATCH {"actor": "admin"} -> 200
version bumped, updated_at moved,
a snapshot appended, nothing changed
GET .../schema-versions -> ["1.0.0", "1.0.0", "1.0.0", "1.0.0"]
A replayed deprecate now returns the dataset untouched. A ledger reader could
not previously tell a retried request from three deliberate deprecations.
An empty patch is a no-op. `{"actor": "admin"}` names no patchable field, and a
body whose values are all `None` passes validation and is then skipped field by
field - so the guard keys on the *effective* change set, not the requested one,
which is what catches the second case. For the same reason the ledger's
`changes`/`before`/`after` now carry only the fields actually applied; a skipped
field used to be recorded as a change that went through.
`/schema-versions` returns distinct versions. A state snapshot is recorded on
every mutation - it captures lineage, mappings, tags, status and scores as well
as the columns, which is what drift detection reads - so a non-schema patch
duplicated an unchanged `schema_version`. The diff resolves a version through a
dict keyed on it and so could only ever reach one snapshot per version: the
listing advertised four and the diff served one. Every listed version is now
addressable, and a real schema change still adds an entry.
The audit rows are kept in every case. A replayed request is a real event and
hiding it would be the opposite defect; what changes is that the row says
`noop: true`. Each guard was reverse-verified by removing it and watching the
matching tests fail, and the tests asserting that a *real* change still bumps
pass without the guards - so they are not merely detecting the guard's presence.
CAT-008 keeps `implemented` with a note recording what the listing used to do.
Verification (local CI parity; repository CI cannot run - account Actions
billing lock rejects every job pre-dispatch):
pytest 978 passed, 11 skipped
pytest, reversed + seeds 11/707/31337 967 passed each, order-independent
pytest tests/fuzz 11 passed
python -m sdp.demo_smoke exit 0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
…immed
`list_policy_decisions` and `list_persisted_audit_events` built their page from
`reversed(_POLICY_DECISION_LOG)` - a lazy iterator over the list itself - while
`record_policy_decision` trims the front of that same list. A reverse iterator
whose list loses its front mid-iteration re-visits shifted elements and skips
others, so a page could carry the same `decision_id` twice and silently omit
that many real rows. Measured with two readers and two writers against a log
already at its cap:
pages read 14303, pages containing a duplicated decision_id 142
After the fix, 0 of 14817 under the same load.
The repair is a snapshot rather than a lock: `list(...)` of a list is one
C-level copy, so it is taken either side of a trim and never mid-trim, and the
read path stays uncontended. `catalog.list_audit_events` already read this way,
which is why only the `evidence` pages tore.
The tests state a limit rather than hiding it. A page is a point-in-time view,
so a row appended after the snapshot is legitimately absent; what they assert is
that no row appears twice and that the window stays within its declared bound -
not that a page is a consistent transactional read, which an in-memory fallback
log does not offer. Append-then-trim is two steps, so one row past the cap is
possible, and that is an extra row rather than a torn page.
Writing them caught a defect in the test itself: both writer threads minted
`written-0`, `written-1`, ... so the duplicates the reader counted were the
test's own rather than the log's. Each writer now carries a unique tag. The fix
was reverse-verified by restoring the live-list reads and watching three of the
five tests fail.
POL-005 keeps `implemented` with a note recording what a page could do, and that
the in-memory log is a bounded window rather than an archive.
This closes the last of the 15 findings from the fourth adversarial sweep.
Verification (local CI parity; repository CI cannot run - account Actions
billing lock rejects every job pre-dispatch):
pytest 983 passed, 11 skipped
pytest tests/fuzz 11 passed
python -m sdp.demo_smoke exit 0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
`CLAUDE.md` states this product's governance invariant: a policy evaluation
*and* an evidence record accompany every catalog mutation and every browse/query
path. The browse paths honoured both halves on a denial - `browse.py` calls
`ingest_event(decision="denied", ...)` - and the four catalog mutations
evaluated policy and then raised. Measured per verb as an unauthorized actor:
PATCH /catalog/datasets/{id} -> 403 decisions +1 audits +0
POST /catalog/datasets/{id}/publish -> 403 decisions +1 audits +0
POST /catalog/datasets/{id}/deprecate -> 403 decisions +1 audits +0
POST /catalog/datasets -> 403 decisions +1 audits +0
A refused attempt is exactly what an auditor looks for, and it appeared in
neither `/audit/events` nor `/catalog/datasets/{id}/audit`.
Found by the requirements-coverage audit while checking NFR-SEC-005's claim that
100% of policy and catalog changes are recorded. The claim was true for writes
and false for refusals, which is the half that matters to an auditor.
`tests/test_governed_write_evidence.py` already asserted the pairing, but its
`GOVERNED_WRITES` list covers only `/ontology/concepts`, `/ontology/patches` and
`/graph/edges` - the three routes sharing `_authorize_graph_write`, which audits.
A hand-written list of governed writes is exactly the shape that leaves out four
inline-authorizing routes, so the new tests derive their coverage from the route
table: a fifth catalog mutation fails until it is given a case or consciously
excluded.
The real reason is recorded even where the response withholds it. An
out-of-scope dataset answers 404 with a generic detail so neither the status nor
the body is an existence oracle, while the ledger - which the refused caller
cannot read - keeps the reason the engine gave. That is the split `browse`
already makes, and a test asserts the two strings differ.
Reverse-verified by removing the four call sites: 9 of the 11 new tests fail.
The two that still pass are the route enumeration and the authorized-write
pairing, neither of which should depend on the denial rows.
Verification (local CI parity; repository CI cannot run - account Actions
billing lock rejects every job pre-dispatch):
pytest 994 passed, 11 skipped
pytest tests/fuzz 11 passed
python -m sdp.demo_smoke exit 0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
Having built the complete matrix, I audited it: eleven agents verified every row
against the code, then an independent agent tried to refute each of the 45
findings they raised (56 agents in total). 38 were confirmed, 7 rejected.
The audit found my status standard had been loose. The standard has to be the
PRD's own acceptance criterion - not whether a feature exists in some form, but
whether the mechanism that requirement names exists. Where the acceptance column
names something this repository does not have, `implemented` is wrong even when
the feature broadly works.
implemented 37 -> 13 partial 20 -> 45
P0 implemented 21 -> 6 (74 requirements, unchanged)
Representative corrections, each reproduced with a code citation:
- CAT-002's detail page must display eleven fields; `source_system` is
deliberately withheld by the governance boundary. Ten of eleven.
- CAT-005's criterion is a change *reason*, and `_build_dataset_payload`
hardcodes `reason="ok"` on every catalog write. Only `deprecate` accepts a
caller-supplied reason, so three of four paths record none.
- CAT-009's criterion is recommendation by business key or ontology mapping.
`get_join_candidates` never reads `dataset.mappings`: two datasets sharing
only an approved mapping return `[]`, while two sharing the column names
`id`/`amount` are recommended.
- POL-003's masking reads `col.pii` alone - not the subject, not the purpose,
not the sensitive/confidential tags the requirement names.
- ONT-002 and ONT-005 require SHACL/OWL verification and a reasoner. Neither
exists.
- NFR-QUAL-004's criterion is a competency-question pass rate, and CQ
registration (ONT-008) is not implemented.
Two corrections went the other way, which is what makes the rest credible.
NFR-SEC-004 moves from `external` up to `partial`: its criterion is TLS on all
*internal and* external communication, and three internal links this repository
owns permit plaintext - the JWKS fetch allows http, the log sink allows http
while carrying an Authorization credential, and the shipped postgres profile
sets `SDP_DATABASE_SSLMODE: disable`. Worse, my note named a Traefik
configuration as the owner of transport security; `grep -rn traefik` matches
only that note. I had carried another repository's shape across and used it to
assign responsibility elsewhere.
NFR-REL-001/002 move from `not_implemented` to `external` because the matrix
contradicted its own vocabulary: `external` is defined as "belongs to the
deployment or the host platform" and names availability targets as one of its
three examples. NFR-SEC-006 moves from `implemented` to `external`: "no data
sent to an LLM provider" holds because no provider boundary exists at all, not
because a control enforces it, and its cited test asserts catalog DSN redaction
and says nothing about model egress.
All 7 rejections were staleness - findings against CAT-001 and NFR-PERF-001..006,
which I had corrected while the audit was running, so the verifier could not find
the status being complained about. That is the cost of running the audit
concurrently with my own edits, and the verification stage caught it.
Three citations were replaced rather than kept: CAT-004's and ONT-003's
concept-graph test asserts glossary openness and neither mapping status nor
hierarchy traversal, and CAT-005's governed-write test touches no catalog route.
The generated document now states the standard and what the distribution means:
45 `partial` rows are not an absence of information, they are information that
lives in the note rather than in the status.
One defect in my own application script was caught by a repository guardrail: a
long `tests/x.py::test_name` citation was wrapped across string literals,
inserting a space inside the identifier, and the anti-dangling-evidence test
failed with three references to tests that do not exist.
Verification (local CI parity; repository CI cannot run - account Actions
billing lock rejects every job pre-dispatch):
pytest 994 passed, 11 skipped
pytest, reversed + seeds 11/707/31337 983 passed each, order-independent
pytest tests/fuzz 11 passed
python -m sdp.demo_smoke exit 0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
186/113 was the value at the time it was written; the branch now carries 199 commits, 122 of them gap-baseline document work. The document already states that it measures its own numbers, so a stale count in that very sentence is the defect it was written against. The two commands that produce both figures are now recorded inline - the second one (counting commits that touch the gap-baseline document) was not written down before, so the split could only be reproduced by guessing at it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
…enting one
CAT-005's acceptance criterion names four things a catalog-write audit row must
carry: the before/after diff, the actor, the timestamp and the **reason**. The
first three were recorded. The reason was not: `_build_dataset_payload`
hardcodes `reason="ok"` - which is the row's outcome, not the caller's reason
for the change - and only `POST .../deprecate` accepted one.
PATCH /catalog/datasets/{id} {"reason": "buyer audit reason"}
-> 200, details keys: after, before, changes, tenant_id (no reason)
A caller's reason was accepted with 200 and dropped, because the request models
declare no such field and pydantic ignores an extra one.
All four write paths now record it as `details.change_reason`. The reason is
read from the raw payload rather than added to the request models on purpose: it
is metadata *about* the change, not dataset state, and those models' fields are
a strict subset of `Dataset`'s - an invariant
`tests/test_catalog_field_forwarding.py` asserts and a `reason` field would
break. A key read outside the model has to be declared or no generated client
could send it, and the request-contract test caught exactly that omission before
these tests existed.
The key is published even with no reason, as `None`. Omitting it would leave a
reader unable to tell "the caller gave no reason" from "this surface does not
record one", which is the distinction the row exists to make.
Writing the tests caught a fabricated default. `deprecate` read
`payload.get("reason", "deprecated")`, turning a reason-less call into the
literal reason "deprecated" - a reason the caller never gave, recorded as though
they had, which is the one thing this surface must not do. All four now report
`None`. Deprecate's own `details["reason"]` is unified to `change_reason` as
well: two keys for one value means a ledger reader filtering on one finds three
of the four paths, or finds the fourth and misses three.
Blank, whitespace-only and non-string reasons are all `None` - "sent an empty
reason" and "sent none" are the same fact, and recording the first would put an
empty string where a reader expects prose. Bounded at 512 characters, because
the payload is caller-supplied and the row is retained.
CAT-005 stays `partial`: the diff half is still narrower than the criterion.
`details.before`/`after` carry only the fields the request named, a register row
has no prior state to diff against, and governed values are projected rather
than recorded verbatim. Its note now says so.
Verification (local CI parity; repository CI cannot run - account Actions
billing lock rejects every job pre-dispatch):
pytest 1008 passed, 11 skipped
pytest, reversed + seeds 11/707 997 passed each, order-independent
pytest tests/fuzz 11 passed
python -m sdp.demo_smoke exit 0
Reverse-verified: removing the four call sites and restoring the "deprecated"
default fails 7 of the 14 new tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
POL-003 (P0) is "PII, sensitive, confidential tag 기반 masking을 지원해야 한다"
with the acceptance criterion "policy decision 결과에 masking rule 포함". The
policy consulted one of the three bases and published no rule:
obligations["masking"] = [col.name for col in dataset.schema if col.pii]
So a column classified sensitive or confidential was **released** - there was
nowhere on `ColumnMetadata` to classify one and nothing that would have masked
it - and a decision said which columns were masked without saying on what
grounds. An auditor could not tell a PII mask from a confidentiality mask, nor
notice that two of the three declared bases were never consulted.
`ColumnMetadata.classification` now carries the label and all three bases mask.
`obligations.masking_rules` gives each masked column its bases and its action,
beside the `masking` name list, whose shape is unchanged because `apply_mask`
and the published response both read it.
An unrecognised label masks too, reported as `unrecognized_classification`.
Masking is a withholding control, so the fail-closed direction is to mask what
is not understood: releasing a column because its label was misspelled is the
failure that matters, and the basis says why rather than silently widening.
`pii: true` and `classification: ["pii"]` are one statement - either masks, so
they cannot disagree about the outcome - and case and surrounding space do not
defeat a label.
`classification` is governed exactly like `pii`: a column's sensitivity label is
as disclosing as its PII flag, so `discovery_column_view` withholds it and the
schema diff reports it changed without publishing the value. A test asserts the
diff carries the field name and not the label.
The `browse.preview` masking floor widened with the policy. It read
`column.pii` alone, so a policy that stopped listing a confidential column would
have released it - a floor narrower than the policy is not a floor - and a test
asserts that from the source.
POL-003 moves to `implemented`, with its note recording what this does not do:
the obligation's action is always `mask` rather than suppress, and nothing
classifies a column automatically - the label is operator-supplied, like `pii`.
Verification (local CI parity; repository CI cannot run - account Actions
billing lock rejects every job pre-dispatch):
pytest 1023 passed, 11 skipped
pytest, reversed + seeds 11/707 1012 passed each, order-independent
pytest tests/fuzz 11 passed
python -m sdp.demo_smoke exit 0
Reverse-verified: restoring the pii-only derivation fails 4 of the 15 new tests,
and the 11 that still pass are the vocabulary and pure-function cases, which
should not depend on the policy wiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
…iewer record
ONT-004 (P0) asks for "ontology version, change log, reviewer 기록" over the
domain ontology. The repository had none of them: the concept vocabulary was a
module-level Python dict with no release identity, so two deployments holding
different concepts were indistinguishable and a publication left no record of
what changed.
Two published claims about the same capability also contradicted each other.
The matrix recorded ONT-004 not_implemented - correctly - while the
rdf_connector control `ontology_version_pin` reported `status: implemented` /
`verification: proof_route_served` with proof endpoints /ontology/search and
/ontology/resolve. Measured, neither response contained the string "version"
anywhere, so the control's own proof routes evidenced nothing about it. And the
only reviewer identity anywhere was OntologyPatch.reviewed_by, which exists on
the patch path only: POST /ontology/concepts published into the shared
vocabulary with no review record, and its one audit row says
`result: "authorized"` and is written before the mutation - measured, an
alias-hijack POST answered 400, published nothing, and left that identical row.
sdp/ontology_version.py owns all three and does not import sdp.ontology (the
vocabulary is passed in, so the dependency stays one-way):
* version: a monotonic `ontology_revision` from a recorded baseline plus a
sha256 `content_digest` recomputed from live content on every read. The
revision is process-local and published as `revision_is_durable: false`;
`digest_matches_recorded_revision` reports a vocabulary mutated outside the
versioned path. The digest is a semantic identity - the unordered fields are
sorted - so reordering a concept's aliases does not refuse a client's pin,
while adding or removing a member still moves it.
* change log: bounded, newest-first, one entry per effective change. A
re-publication of an existing concept is a no-op and records nothing; an
eviction at the vocabulary cap is its own revision, which is the first time
that silent deletion left any trace at all.
* reviewer record: an approved patch records the proposer and the approving
reviewer; a direct upsert records `reviewer_id: null` with
`review_basis: "direct_admin_upsert"`, and the recorder refuses a reviewer on
any source that carries no review. Each entry names its `actor_role`, because
`actor_id` is the publisher on one path and the proposer on the other.
GET /ontology/vocabulary/jsonld serializes the vocabulary as SKOS/RDFS JSON-LD
carrying owl:versionInfo - the artifact the revision names - hand-written like
the existing DCAT export, with no new dependency. It mints no IRI for the 10
hierarchy edges whose targets the vocabulary never defines and declares them
instead, resolves an alias-only target through the alias index, and carries no
dataset ids (ontology._INDEX has no tenant predicate; serving it is how
concept_assets leaked every tenant's dataset ids).
/ontology/search and /ontology/resolve now stamp the version they answered from
and accept `ontology_content_digest` as a pin: 409 on a mismatch, 400 on a
malformed digest. The revision is not pinnable, because a pin on a process-local
number silently stops pinning after a restart. The connector control is graded
runtime_probe_verified by exercising the predicate in both directions, and the
runtime-check seat is generalized to a control->check dispatch that
_RUNTIME_VERIFIED_CONTROLS derives from.
Disclosure is split: the version identity and the SKOS artifact are open and
carry no identity; the change log is gated through the same helper as
/audit/events and /policy/decisions.
Also fixed, found while doing this:
* the concept IRI was not injective - `활성 고객` and a separately registrable
`활성-고객` minted the same identifier, which in RDF merges two concepts into
one subject. Only the characters that cannot appear in an IRI segment are
escaped, so Unicode stays readable.
* /ontology/resolve published term IRIs for dataset-declared terms the glossary
never defines (캠페인, 전환, 비즈니스 용어), shaped identically to a defined
concept's IRI. Each row now carries `defined_in_vocabulary`.
* vocabulary mutation had no lock, while catalog and graph_store both added one
for measured check-then-act corruption. One reentrant lock spans the
vocabulary, the alias index, the revision counter and the patch status.
* authz._JWKS_CACHE and observability._SINK_BREAKER were outside the autouse
isolation fixture, with per-file cleanup instead. Measured leakage over the
whole suite: 0 for both, so latent rather than live. Both are in the fixture
now, and tests/test_module_state_coverage.py derives the coverage set by
enumeration so the next one cannot be silent.
ONT-004 moves to partial, not implemented: the requirement says OWL/RDFS and
there is no OWL axiom and no reasoner validating the release (ONT-007,
NFR-QUAL-002), and the revision, log and reviewer records are process-memory
only. ONT-003's and NFR-QUAL-002's notes are corrected where this change
falsified them, and ONT-005's note records the open reviewer-identity
disclosure on GET /ontology/patches, which this change deliberately contains
rather than widens.
Local evidence (the account's Actions are billing-locked; last non-Dependabot
success 2026-10-02T09:03:05Z):
pytest 1080 passed, 11 skipped
pytest (forward/reversed/shuffled file order, seeds 11 and 707)
1069 passed, 11 skipped each - order independent
pytest tests/fuzz 11 passed
python -m sdp.demo_smoke exit 0
Reverse-verified: replacing the publication lock with `if True` yields two
`already_published: False` rows for one concept and advances the revision twice;
recording the change before the vocabulary write breaks the entry's digest;
dropping the REVIEWED_SOURCES check lets a caller stamp a reviewer on an
unreviewed publication; adding an unclassified module store fails the new
coverage guard; deleting one restore line from the fixture fails its claim test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
ONT-005 (P0) asks for "reasoner + SHACL + reviewer 승인 후 병합". Reviewer
approval was the whole gate: review_patch went from an approve decision straight
to register_concept, whose checks are a non-empty name, a length bound, alias
conflict and the vocabulary cap. Nothing looked at the concept being published,
and semantic_validation had no concept-targeting shape at all.
Measured over HTTP before this change:
POST /ontology/concepts {"actor": "admin", "concept": "정의누락개념"}
-> 200, stored definition = ''
GET /ontology/search?q=정의누락개념 -> count 1, definition = ''
GET /ontology/concept/정의누락개념 -> definition = ''
So the shared glossary every read surface consults could hold a concept with no
definition, published through an authorized route. A glossary whose terms have
no definitions is not a glossary.
ConceptShape joins the published shape catalog and is evaluated by
validate_concept_registration - the shared pre-write validator - so both
publication paths are covered by one check and a third caller cannot bypass it.
On the approval path register_concept already runs before the status write, so a
refusal leaves the patch "proposed" rather than "approved with nothing
published".
The constraint choice follows measurement, not preference:
* definition is the only property that can honestly be required. A top concept
has no broader and a leaf no narrower, so requiring either refuses correct
vocabulary; and propose_patch produces only concept + suggestion, so a
minCount on aliases, multilingual or broader would refuse every patch
approval.
* the sh:class-shaped constraint that a hierarchy target must itself be a
Concept is declared at warning severity. The shipped vocabulary violates it
10 times over 9 distinct undefined names, and raised to a violation it makes
all five seeded concepts non-conforming (measured by reverting it). A
constraint that refuses the product's own seed cannot gate publication.
A second defect found while verifying the first, and the same root cause: the
gate's idea of "present" was str.strip(), which removes only characters
str.isspace() accepts - and the Unicode format category is not whitespace.
Measured, a definition of a single U+200B, U+200C, U+00AD, U+200E, U+2060,
U+FEFF or NUL passed the gate and /ontology/search served a definition that
renders as blank. The concept *name* had it too: three concepts named U+200B,
U+FEFF and NUL were published with 200, because the only check there was a
non-empty .strip(). One predicate closes both - at least one character outside
the Unicode separator and control/format categories - and the shape publishes
that rule as required_property_basis, so the enforced rule and the declared one
cannot drift. It is deliberately stricter than sh:minCount 1, which an invisible
literal satisfies, and it is "has a visible character", not "has no invisible
ones", so " real definition " conforms.
Each report now names the shapes it applied. The catalog is four shapes, so a
dataset report publishing the catalog alone would read as having validated a
dataset against ConceptShape - the exact misreading this module's history is
about. test_every_published_required_property_is_evaluated's enumeration is
extended to ConceptShape, so a published required property that nothing
evaluates fails there. /enterprise/shacl-validation publishes
concept_conformance across the whole vocabulary, so the shape's effect is
measurable platform-wide rather than only at a publication.
The strength is bounded and published as such: the engine is
builtin_shape_checks and shacl_engine_executed stays false. The shapes are
SHACL-expressible and enforced by equivalent in-process checks, which is the
strongest honest claim without an RDF stack - pyshacl and rdflib are not
dependencies, CI installs from hash-pinned requirements, and
tests/test_semantic_validation.py pins that absence with
pytest.raises(ImportError).
ONT-005 stays partial: there is no reasoner (ONT-007), a property-presence
shape is not a consistency check, and the export carries no OWL axiom for a
reasoner to act on - so NFR-QUAL-002 stays unmet, and its note is corrected
where it said the publication path applies no SHACL shape.
Also: the /ontology/vocabulary/jsonld docstring said a flat "no dataset ids".
An adversarial review measured that once a concept is registered under a name
that is a dataset id - which nothing refuses, ONT-002's recorded gap - that id
reaches the document's @id. The guarantee is about derivation, and the route
docstring now says so, matching what the test docstring already stated.
Local evidence (the account's Actions are still billing-locked; the two checks
that dispatched on 1a62855 were rejected pre-dispatch with "The job was not
started because your account is locked due to a billing issue.", steps: 0,
3-second jobs, and the last non-Dependabot success remains
2026-10-02T09:03:05Z):
pytest 1112 passed, 11 skipped
pytest (forward / reversed file order)
1101 passed, 11 skipped each
pytest tests/fuzz 11 passed
python -m sdp.demo_smoke exit 0
Reverse-verified: dropping the conforms check fails three gate tests; raising
the hierarchy constraint to a violation makes all five seeded concepts
non-conforming; reverting the presence rule to str.strip() fails all seven
invisible-character cases.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
…its own stale counts The evidence block said "순서 무관 통과" without the number, so a reader could not tell what was measured. It is 1101 passed / 11 skipped in each of five file orders (forward, reversed, seeds 11/707/31337), and 1112 in the full run - the 11-test difference is tests/fuzz, which the file-order sweep does not include. Stated, because an unexplained gap between two published counts reads as one of them being wrong. The preamble's history line also said the count was "corrected to 203" while the headline already read 204. Both values were right at the moment they were written and the line is the file's own record of that going stale, so it now carries 203/122/81 too, and says which commit made each one stale. Also recorded: the order-independence numbers are only measured on a frozen tree. Editing the tree during a background sweep produced a phantom "seed 11: 4 failed" in this session, which was concurrent editing and not a defect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
…'s own defects
ONT-002 records that concept, dataset and column are distinct node kinds and
that nothing verifies it. Measured, the consequence was worse than a modelling
violation. `upsert_node` built a fresh GraphNode and stored it over whatever was
there, so one authorized request destroyed a dataset:
GET /graph/nodes/crm-customer-master kind=dataset
label='고객 마스터'
properties={domain, owner, steward, tags, terms,
sensitivity: 'medium', status: 'published'}
POST /ontology/concepts {"actor": "admin",
"concept": "crm-customer-master",
"definition": "...", "broader": "고객"}
-> 200
GET /graph/nodes/crm-customer-master kind=concept
label='crm-customer-master'
properties={definition, aliases, multilingual}
An ontology write silently erased a dataset's classification - sensitivity,
owner, steward, status - which is the metadata the tenant boundary and the
masking obligations reason about and which /search/semantic and /graph/query
serve. The tenant half of that boundary was already closed; this was the kind
half, open to any authorized writer.
A node's kind is now immutable. GraphStore.assert_node_kind_is_stable refuses a
write that would reclassify an existing id, and
ontology.validate_concept_registration asks it before either store is written,
so all three publication paths answer 409: POST /ontology/concepts,
POST /graph/nodes {kind: concept}, and an approval through
POST /ontology/patches/{id}/review. That third path is why the check cannot live
in the store alone - it writes only the vocabulary, so a store-side guard missed
it entirely and approving a patch named crm-customer-master answered 200 and put
a dataset id into the vocabulary and the open SKOS export. ConceptKindConflict
is a ValueError subclass, so every existing handler still catches it while both
routes answer the same status for the same conflict.
It is deliberately not a kind allow-list: kind stays free-form, so a new kind is
still introducible and the injection-regression tests that pass a hostile string
as a kind still hold. What is refused is reclassifying an id that already
exists, which refuses the destruction and nothing legitimate - a same-kind
upsert is how the catalog mirror, the seed and concept re-registration all work.
The Postgres store reads the stored kind inside its write transaction with FOR
UPDATE rather than through get_node, which would open its own connection and
leave a window between the check and the upsert.
Two adversarial verification rounds against the previous commit's ConceptShape
gate found ten further defects, nine of them that commit's own. All reproduced
independently before fixing:
* the gate refused updating an already-published concept's graph edges (200 ->
400), because it evaluated the submitted definition that the registrar
discards - and the only way through wrote that discarded definition to the
graph store, so one 200 body carried a graph definition the vocabulary never
accepted. The required-property check now applies to new concepts only.
* the two stores diverged on a concept's definition: a re-upsert omitting
definition left GET /ontology/term/{term}/graph serving 고객 with
definition: ''. The graph mirror now carries the vocabulary's definition. A
retained test caught this, in reversed file order.
* definition was mandatory and unbounded: a 2,000,000-character definition was
stored in full, in a 500-slot process-lifetime vocabulary, while the concept
name was bounded at 2000 three lines from the gate and the patch path bounded
the same field at intake. The shape publishes max_property_length and both
paths use one constant.
* patch intake tested suggestion.strip() while the gate tested visibility, and
suggestion *is* the definition - so a U+200B, U+FEFF, U+00AD or NUL suggestion
entered the shared steward queue with 200 and could never be approved, pinned
at proposed forever. One predicate for one field, applied in propose_patch so
the library seam is covered too.
* concept_vocabulary_snapshot was a shallow copy, so the lists the documented
read-only accessor handed out were the live stored lists: appending to the
returned narrower installed an unresolvable hierarchy target into the
published vocabulary, and an alias took a term the gate refuses.
* refusals left only an allow in the ledger: a refused approval wrote one policy
decision with effect allow and zero audit rows, a refused upsert wrote a row
reading authorized - written before the mutation, so it cannot attest the
publication happened. Both now write their real outcome.
* an approval of a patch whose concept already exists writes the status and
publishes nothing; recorded as plain approved that reads as a publication, so
the audit outcome is approved_without_publication.
* the store write preceded the vocabulary write and is unconditional, so a
refusal from register_concept answered 409 with the graph node already
written. The vocabulary is written first and the store mirrors it.
* a non-string definition raised AttributeError, which neither route maps, from
a function documented to refuse with ValueError. Refused, not coerced: str(5)
would publish "5" as a definition.
* and the one worth naming: test_the_gate_cannot_be_bypassed_by_a_future_caller
asserted its own name. It only checked that the validator refuses, which
another test already covered, and said nothing about write sites or callers. A
reviewer appended sixteen lines writing _CONCEPT_DEFINITIONS directly and
measured the whole suite green - that test included - while definition-less
concepts reached every ontology read surface. "Cannot be bypassed" is a
property of the code's shape, so tests/test_concept_write_chokepoint.py checks
it by AST, in the same enumeration idiom as test_module_state_coverage.py:
every vocabulary write site is declared, the publishing function's first
executable statement is the shared validator, and register_concept's caller
set is exactly the two declared entry points. The old test is renamed to what
it actually checks.
Defect-pinning tests changed, named as the constraint requires:
test_the_export_never_derives_dataset_identity asserted the hijack upsert
succeeded with 200 - that 200 was the defect - and now asserts 409 plus the
dataset node's survival, with the residual (a dataset-id-shaped name colliding
with nothing is still accepted, ONT-002) pinned separately.
test_a_governed_write_records_a_decision_and_an_audit_event expected the
pre-mutation "authorized" row for this route; it now expects the real outcome,
which is the second case that table's own comment describes.
Also: test_requirements_coverage's func: pointer resolver only handled
module.attribute, so a method was rejected as "is not a package" - a pointer it
could not express rather than one that did not exist, which is the wrong failure
for a test that catches dangling evidence. It now walks the dotted path from the
longest importable prefix; a nonexistent method, class or module is still
refused with its own reason.
ONT-002 stays partial. The edge half is open: no kind check guards
POST /graph/edges, so a broader edge from a dataset node to a concept is still
accepted, and that is the class-separation violation the acceptance criterion is
about. ONT-005 stays partial (no reasoner). Not fixed here and recorded:
shacl_compatible is named for SHACL expressibility but computes evaluation
coverage; renaming a published key is a contract change.
Local evidence (the account's Actions are still billing-locked; the checks on
1a62855 and f769ca7 were rejected pre-dispatch with "The job was not started
because your account is locked due to a billing issue.", steps: 0, and the last
non-Dependabot success remains 2026-10-02T09:03:05Z):
pytest 1150 passed, 11 skipped
pytest (forward / reversed file order)
1139 passed, 11 skipped each
pytest tests/fuzz 11 passed
python -m sdp.demo_smoke exit 0
Reverse-verified: removing assert_node_kind_is_stable fails 6 of the kind tests
and the dataset's properties come back as the concept's three keys; the
reviewer's exact sixteen-line bypass fails the new chokepoint contract while the
old test passes; reverting the graph mirror fails the divergence test; reverting
the presence rule to str.strip() fails all seven invisible-character cases.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
…ned read
`GET /ontology/patches` ran no authorization at all. Measured, after one
proposal by `analyst` approved by `admin`:
GET /ontology/patches 200 requestor=analyst
reviewed_by=admin
reviewed_at=2026-10-14T18:44:32Z
review_comment='스튜어드 승인 사유'
GET /ontology/patches?user=anonymous 200 (the same four fields)
GET /ontology/patches?user=nobody 200 (the same four fields)
at the same moment as, for the same identities:
GET /ontology/change-log 403
GET /audit/events 403
GET /policy/decisions 403
ONT-004 gated the change log on the argument that {actor_id, reviewer_id,
recorded_at} *is* an access record, and recorded this disagreement rather than
using it as precedent. The argument applies unchanged to a surface that serves
that record plus the reviewer's comment, so the queue read now runs the same
helper, `_authorize_evidence_read`, with the same reasoning: the vocabulary is
shared rather than tenant-owned, so no tenant predicate applies and the scope
that does is authorization. Authorization precedes the `?status=` validation,
matching `ontology_patch_review` - an unauthorized caller must not learn the
payload rules - so `?status=nope` answers 403 without an actor and 400 with one.
The capability is scoped, not removed: an authorized actor reads the whole queue
including all four fields, because a steward has to know who proposed a change
to judge it.
It was two surfaces, not one. Enumerated across every GET route the router
exposes, with bodies walked for a mapping key rather than grepped for a field
name, `/enterprise/steward-review` also republished `requestor`. Gating only the
queue would have left the name reachable. That summary is a published open proof
link pinned to resolve below 400, and the procurement question it answers - how
deep is the queue, is the handoff ready - does not need the proposer, so it
withholds the field and declares the withholding. The field is dropped for every
caller: an actor-dependent identity field there would need a predicate that
writes no decision row, leaving an authorization decision with no evidence.
Gate rather than redaction, for a measured reason: across every GET route, the
19 that evaluate policy all answer 403 without an actor, and none answers 200
while writing a ledger row. Serving a redacted 200 from a policy evaluation
would have made this the only read in the product billing the ledger for an
anonymous success - an unbounded-growth surface an unauthenticated caller
controls. `test_the_scoped_route_is_not_a_new_ledger_write_surface` states that
invariant for one route; it is now pinned platform-wide.
Three defects in the verification instrument itself, all found by running it
rather than reading it:
* it searched the response text for the field name and matched
`"withheld_review_item_fields": ["requestor"]` - the declaration that the
field is absent. A declaration is not a disclosure.
* it then skipped every body it could not parse as JSON, which is exactly the
two routes answering 200 with HTML and text, so a server-rendered queue would
have passed. Reverse-verified by planting `list_patches()` into the console
HTML: two detectors fire. `requestor`/`reviewed_by` cannot be planted as
distinctive strings (only a resolvable actor passes the gates, and `analyst`
as a substring matches `data-analyst` in the RBAC matrix), so the non-JSON
surfaces are covered by proving they hold no queue row at all - the
precondition for rendering one's proposer - and that surface list is itself
enumerated.
* the first attempt at that fix over-fired, treating the planted concept names
and the patch id as leaks. They appear on five surfaces and each is correct by
design: an approved patch publishes its concept into the open vocabulary, and
the steward summary publishes the queued row's id, concept, status and
confidence. Asserting those were disclosures would have been a false claim in
the opposite direction.
`withheld_review_item_fields` is derived from the items rather than written as
`["requestor"]`: a literal would keep claiming the field is withheld after
someone put it back. Reverse-verified - reintroducing it shrinks the declaration
to the three remaining fields and fails four tests.
ONT-005's note recorded this disclosure as deliberately deferred; that record is
corrected, with the evidence and the two surfaces named. The 403 is declared in
`api_error_contract` in the same change, and the AST-derived contract test fails
in both directions if handler and table are split (verified both ways). README,
`docs/implementation-compliance.md`, `docs/enterprise-readiness.md`,
`docs/requirements-coverage.md` (regenerated) and the response contract
(regenerated) follow.
Local CI-equivalent verification on the frozen tree:
pytest 1162 passed, 11 skipped
pytest (forward/reverse/ each 1151 passed, 11 skipped - order-independent
seed 11/707/31337)
pytest tests/fuzz 11 passed
python -m sdp.demo_smoke exit 0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
범위 변경 고지 (2026-10-05)
이 PR은 문서 전용으로 시작했고 지금은 문서 + 제품 코드입니다. 아래 A·B 절은 원래의 문서 내용이고, 새로 더한 것은 C 절입니다. 리뷰 시 둘을 분리해 읽으십시오.
sso_oidc_adapterenterprise control을 구현했습니다 — 이 저장소에 남아 있던 유일한plannedcontrol이고, 격차 기준선 문서가 "실제 제품 갭은 이것 하나"라고 추적해 온 항목입니다. 코드가 이 브랜치에 들어온 이유는 운영 제약입니다: 이 세션은claude/semantic-portal-pr-merge-e5a48k외의 브랜치로 push할 권한이 없습니다. 그 결과 이 feature의 main 도달은 #79 → #102 문서 체인에 묶여 있습니다. 분리가 필요하다고 판단하시면 그렇게 지시해 주십시오 — 제 쪽에서 별도 브랜치를 만들지는 않았습니다.C. 2026-10-05 추가 —
sso_oidc_adapter구현release criteria 셋 중 ②("group-to-role 매핑이 tenant scoped이고 감사 가능")만 비어 있었습니다. ①(issuer·audience·JWKS 환경 구성)은
verify_oidc_jwks_token이, ③(누락·만료 claim 거부, 직접 role claim 무시)은validate_oidc_claim_shape/oidc_role_claims가 이미 만족하고 있었습니다. ②는 두 단어 모두 비어 있었습니다 — 매핑이 tenant 구분 없는 평면 map이었고, 매핑 결과가 evidence store에 전혀 남지 않았습니다.tenant scoping.
normalize_group_role_map()이 평면{"group": ["role"]}을 모든 tenant에 적용되는 wildcard scope(*)로,{"tenant": {"group": ["role"]}}을 tenant 전용 scope로 정규화합니다.tenant_scoped_group_roles()가 wildcard를 깔고 token tenant의 scope로 같은 group 키를 덮어씁니다. 합집합이 아니라 교체를 고른 이유는 그렇게 해야 tenant scope에서 빈 role 목록으로 wildcard 부여를 회수할 수 있기 때문입니다. 두 형태를 섞은 map과 빈 tenant scope 키는 400으로 거절합니다 — 둘 다 tenant 식별자를 group 이름으로 잘못 읽는 설정 오류이고, 조용히 절반만 적용하는 쪽이 더 위험합니다. 명시적으로 빈role_map은 설정 fallback이 아니라 "아무것도 부여하지 않음"입니다.감사 가능성.
oidc_group_role_bindings()가 group마다 한 행(group_name, 부여한tenant_scope,granted_roles)을 만들고, 어떤 scope도 매핑하지 않은 group도 빈 role로 남깁니다 — 거절을 보이게 남기지 않으면 "왜 이 사용자가 role을 받지 못했는지"를 사후에 읽을 수 없습니다.record_oidc_mapping_audit_event()가 그 행들을enterprise/auth/oidcresource audit event로 기록하고,oidc-preview·oidc-verify두 endpoint가group_role_bindings와audit_event_id를 응답에 함께 돌려줍니다. eventdetails키는 정확히mapping_mode,tenant_id,granted_roles,group_bindings,ignored_role_claims다섯 개이며 token·서명·raw claim payload는 응답에도 evidence store에도 들어가지 않습니다 — 테스트가 그 집합을 고정하므로 나중에 claim을 하나 더 흘리면 깨집니다.control을
implemented로 올렸고, manifeststatus는 planned 수에서 파생되므로pilot_ready가 됩니다(planned_controls1 → 0,implemented_controls5 → 6).검증 (로컬 CI parity)
PYTHONPATH=src pytestpytest tests/fuzz(Hypothesis)src/sdp/authz.pystatement/branch coveragesrc/sdp_core/enterprise.pycoveragepython -m sdp.demo_smokeready: true,implemented_enterprise_controls: 6, exit 0Python 3.12 + hash-pinned
requirements-test.txt로 CI의 설치 명령·인터프리터를 그대로 썼습니다. 신규 19건 중 12건은tests/test_authz.py, 7건은tests/test_api.py이고, 그 7건 중 5건은 이 두 endpoint의 payload 검증 가드입니다 — 제 변경이 만든 공백이 아니라 기존 공백인데 같은 endpoint를 건드리는 참에 닫았습니다. 그 결과 OIDC 영역에 커버되지 않은 줄은 남아 있지 않습니다.이 증거의 한계 (중요)
CI는 이 변경을 검증하지 않았습니다. 2026-10-02 18:44Z~22:30Z 사이에 걸린 계정 과금 잠금이
ubuntu-latestjob을 배정 전에 거절하고 있고, 2026-10-05 05:52:48Z에도 활성입니다(.githubPR #2581의 실행37269721729, check-run annotation으로 확인). 따라서 위 표는 required check의 대체물이 아닙니다. 잠금이 풀리면 이 head의 체크를 먼저 돌려 로컬 결과와 대조해 주십시오.그리고 이것은 control 하나를 닫은 것이지 SSO 통합을 완성한 것이 아닙니다. 이 저장소는 여전히 local IdP도 session 발급도 갖지 않습니다 — 로그인 흐름·세션·token 갱신은
keyverse(cwl-idp)가 소유합니다./browse/{dataset_id}/preview가payload.get("user", "anonymous")로 호출자 주장 신원을 그대로 믿는 문제는 이 변경이 건드리지 않았습니다; 검증된ActorContext를 데이터 경로의 신원으로 쓰는 것은 별개의 변경입니다.D. 2026-10-14 추가 — 요구사항 전수 매트릭스, 자기 감사, 동시성 수리
상세는
CHANGELOG.md에 있습니다(이 PR의 diff에 포함). 여기서는 무엇이 바뀌었고 왜인지만 적습니다.D-1. 요구사항 대응이 전수가 아니었습니다
CLAUDE.md가 요구사항 대응 매트릭스로 지목하는docs/implementation-compliance.md를 PRD에 대고 측정한 결과: PRD의 요구사항 74건 중 문서가 언급하는 것은 13건, P0 34건 중 25건이 없었습니다. 빠진 것들이 대부분 실제로 구현되어 있다는 점이 문제를 더 나쁘게 만듭니다 — 읽는 사람은 구별할 수 없고, 부재는 부재로 읽힙니다.docs/requirements-coverage.md는 생성 문서입니다. 목록을 PRD에서 파싱하므로 원본과 어긋날 수 없고, 모든 ID가 명시적 상태와 증빙 포인터를 가져야 합니다.tests/test_requirements_coverage.py가 PRD ID의 전수 매핑, 반대 방향(PRD가 선언하지 않는 ID 금지), 모든 포인터의 실제 해석(route / attribute / pytest가 수집하는 test id),implemented주장의 수집되는 test 보유, 약한 상태의 사유 기재, 문서와 생성 결과의 글자 단위 일치를 강제합니다. 역검증으로 10가지 훼손이 모두 실패를 일으키는 것을 확인했습니다 — 그중 하나가 PRD에 요구사항이 추가되면 답할 때까지 스위트가 깨진다는 것입니다.D-2. 그 매트릭스를 적대적 감사에 걸어 38건을 정정했습니다
만든 직후 스스로 감사했습니다(요구사항군별 에이전트 11개 → 제기된 발견 45건을 각각 독립 에이전트가 반증 시도, 총 56 에이전트). 36~38건 확인, 7건 기각(전부 제가 감사 도중 이미 고친 것).
판정 기준이 제 초기 매핑에서 느슨했습니다. 기준은 PRD의 수락 기준(ACCEPT 열) — 기능이 존재하는지가 아니라 그 요구사항이 명시한 메커니즘이 있는지입니다.
implementedpartialimplemented예:
CAT-005의 수락 기준은 변경 사유 기록인데_build_dataset_payload가 모든 카탈로그 쓰기에reason="ok"를 하드코딩합니다(4개 경로 중 3개가 사유 없음).CAT-009의 수락 기준은 business key/ontology mapping 기반 추천인데get_join_candidates는dataset.mappings를 읽지 않습니다 — 승인된 mapping만 공유하는 두 데이터셋은[]를,id/amount같은 일반 컬럼명만 공유하는 둘은 추천을 받습니다.POL-003의 마스킹은col.pii만 보고 subject·purpose·PRD가 호명한 tag를 보지 않습니다.반대 방향도 있습니다.
NFR-SEC-004를external→partial로 올렸습니다: 수락 기준이 "모든 내부/외부 통신 TLS"이고 이 저장소가 소유한 내부 링크 세 개가 평문을 허용합니다(JWKS fetch가 http 허용, log sink가 Authorization 헤더를 지고 http 허용, 배포되는 postgres 프로파일이SDP_DATABASE_SSLMODE: disable). 게다가 제 비고가 이 저장소에 없는 Traefik 설정을 책임자로 지목하고 있었습니다(grep -rn traefik→ 제 비고 자신만). 다른 저장소의 모양을 가져다 근거 없이 책임을 전가한 것입니다.D-3. 동시성 — 유실된 쓰기, 거짓 증빙, 그래프 모순 (별도 스윕,
high4건)FastAPI는 이 패키지의
def핸들러를 워커 스레드풀에서 실행하므로 두 요청이 실제로 교차하는데, 모듈 dict에 대한 read-modify-write를 직렬화하는 것이 없었습니다.success로 인증했습니다(교차를 강제해 측정: 저장 상태는 한쪽만 반영, version 1회만 전진, 감사 행 2개 모두success). 직렬화 없이 동시 PATCH 50건이 version을 25만 전진시켰습니다.pii: true컬럼을 보유하고/search/semantic이 최상위로 반환,/browse/{id}/schema는 마스킹 불가./search/semantic이 온보딩 중 500(42건 중 17건) —_embeddings를 순회 중 증가.회귀 테스트는 경쟁을 기다리지 않고 교차를 강제합니다(임계 구역 안의 barrier는 검증하려는 락과 교착하므로 일시 정지). 각 수리를 되돌려 해당 테스트가 실패하는 것을 확인했습니다.
D-4. 그 외 수리
reversed(살아있는 리스트)를 trim이 밀어내는 동안 순회. 수리 후 14817 중 0.requirements-dev.txt가devextra가 선언한hypothesis/sqlalchemy를 빠뜨렸습니다(CI는requirements-test.txt를 쓰므로 알아챌 수 없었습니다). 수리 후 같은 깨끗한 venv에서 968 passed.updated_at·snapshot을 움직였습니다(publish는 이미 guard하고 있었습니다).col.pii만 읽어 sensitive/confidential로 분류된 컬럼이 공개되었고(분류를 적을자리 자체가 없었습니다) 판단은 어떤 근거로 마스킹했는지 말하지 않았습니다.
ColumnMetadata.classification을 두고 세 근거 전부가 마스킹하게 했으며,알 수 없는 라벨도 마스킹합니다(fail-closed — 라벨 오타로 컬럼이 공개되는 것이
문제가 되는 실패입니다).
classification은pii와 같은 방식으로 거버닝됩니다.셋이
reason="ok"를 하드코딩했고(그것은 행의 결과이지 변경 사유가 아닙니다)deprecate는 사유 없는 호출을 문자열 "deprecated" 라는 사유로 기록했습니다 —호출자가 주지 않은 사유입니다. 네 경로 모두
details.change_reason으로 기록하고,없으면
None입니다.within_budget이 아니라budget_refuted), 품질 임계값 위반 경고(LIN-004), profile 수치의 측정 출처 공개(LIN-003 —computed_by_this_service: false, 이 서비스는 프로파일러가 없습니다).D-5. 검증
PYTHONPATH=src pytestpytest tests/fuzzpython -m sdp.demo_smokeCI는 여전히 이 변경을 검증하지 않았습니다. 계정 Actions 과금 잠금이 job을 배정 전에 거절합니다(이 head에서도 3초 job +
The job was not started because your account is locked due to a billing issue.annotation 확인). 저장소 자체 워크플로의 마지막 성공은 2026-10-02T09:03:05Z이고, 그 이후의 성공은 GitHub 자체 할당량으로 도는 Dependabot뿐이며 이는 계정 잠금 해제의 증거가 아닙니다. 위 표는 required check의 대체물이 아닙니다.E. 2026-10-14 4차 — ONT-004: 온톨로지 버전·변경 로그·심사자 기록 (
1a62855)ONT-004(P0) "OWL/RDFS 기반 도메인 ontology를 버전관리해야 한다", 수락 기준
"ontology version, change log, reviewer 기록". 세 가지 모두 없었고, 같은 역량에
대한 두 게시 주장이 서로 모순이었습니다:
not_implemented(맞는 기록)rdf_connector컨트롤ontology_version_pin:status: implemented/verification: proof_route_served, proof endpoint는/ontology/search와/ontology/resolve. 실측: 두 응답 어디에도 "version"문자열이 없습니다. 컨트롤의 proof route가 그 컨트롤을 전혀 증명하지 않았습니다.
OntologyPatch.reviewed_by뿐 — patch 경로 전용입니다.POST /ontology/concepts는 공유 어휘에 심사 기록 없이 게시하고, 그 감사 행은result: "authorized"이며 변경 이전에 기록됩니다. 실측: alias 탈취 POST가400으로 거부되어 아무것도 게시되지 않았는데도 동일한 "authorized" 행이 남습니다.
게시가 실제로 일어났다는 증거가 어디에도 없었습니다.
src/sdp/ontology_version.py(722줄,sdp.ontology를 import하지 않습니다 — 어휘를인자로 받아 의존을 한 방향으로 유지)가 세 가지를 소유합니다:
ontology_revision+ 매번 다시 계산하는 sha256content_digestrevision_is_durable: false).digest_matches_recorded_revision이 버전 경로 밖의 어휘 변경을 보고합니다reviewer_id: null+review_basis: direct_admin_upsertactor_role—actor_id는 경로마다 뜻이 다릅니다(게시자 vs 제안자)digest는 의미 기준입니다: 순서 없는 필드를 정렬해 해싱하므로 alias 순서만 바뀐
두 배포가 서로 다른 버전을 보고해 클라이언트의 pin을 거절하지 않고, 멤버를 더하거나
빼면 여전히 움직입니다.
revision이 이름 붙이는 artifact:
GET /ontology/vocabulary/jsonld가 어휘를SKOS/RDFS JSON-LD로 직렬화하고
owl:versionInfo에 revision+digest를 담습니다(기존DCAT export와 같은 방식 — 손으로 쓴 inline
@context, 새 의존성 없음). 그 전에는RDF artifact가 전혀 없었습니다. export가 선언하는 것: 정의되지 않은 10개 계층 edge는
IRI를 만들지 않고 선언(실측 19개 edge / 13개 distinct target / 9개 미정의 이름),
alias 전용 target은 alias index로 해소(휴면 고객 → 이탈 고객), dataset id 없음
(
_INDEX에 tenant 술어가 없고, 그것이concept_assets가 전 테넌트 dataset id를흘렸던 경로입니다).
버전 고정(pin): 두 proof route가 답한 버전을 스탬프하고
ontology_content_digest고정을 받습니다 — 불일치 409, 형식 오류 400(오타를 "서버가다른 어휘를 들고 있다"로 보고하면 클라이언트가 잘못된 조치를 합니다). revision은
고정 대상이 아닙니다(
revision_is_pinnable: false) — 재시작 뒤 내용이 달라도 같은번호를 다시 쓰므로 조용히 고정이 풀리는 pin이 됩니다. 이로써 컨트롤은 route 존재가
아니라 predicate를 양방향 실행해
runtime_probe_verified로 등급이 매겨집니다.공개/거버닝 분리: 버전 정체성과 SKOS artifact는 공개(식별자 없음), 변경 로그는
/audit/events·/policy/decisions와 같은 게이트(익명 403).하면서 발견해 같이 고친 것
활성 고객과 따로 등록 가능한활성-고객이동일 IRI를 발급합니다. RDF에서는 두 개념이 한 subject로 합쳐져 레이블·정의·계층이
병합됩니다. IRI 세그먼트에 올 수 없는 문자만 escape해 Unicode 가독성을 유지했습니다.
/ontology/resolve가 정의되지 않은 용어에 정의된 개념과 똑같은 IRI를 붙였습니다(캠페인·전환·비즈니스 용어). 행마다
defined_in_vocabulary를 둡니다.catalog·graph_store는 측정된 check-then-act손상 때문에 이미 재진입 락이 있었습니다. 회귀 테스트는 경쟁시키지 않고 읽기
안에서 첫 writer를 멈춥니다(이 구역에는 GIL을 놓는 호출이 없어 CPython에서는
창이 저절로 열리지 않습니다).
authz._JWKS_CACHE,observability._SINK_BREAKER)이었고 두 파일이 각자 치우고 있었습니다. 전체스위트 probe로 측정한 유출은 0건 — 잠재적 취약성입니다. 둘을 fixture로
옮기고, 새
tests/test_module_state_coverage.py가 두 패키지의 모듈 수준가변 컨테이너를 열거해 분류를 강제합니다.
상태는
partial입니다 —implemented가 아닙니다세 수락 항목 모두 구현·테스트되었지만 요구사항은 OWL/RDFS를 말하고 OWL axiom은
하나도 없으며 reasoner가 release를 검증하지 않습니다(ONT-007, NFR-QUAL-002). revision·
변경 로그·심사자 기록은 프로세스 메모리 한정입니다. ONT-003과 NFR-QUAL-002의 note는
이 변경이 거짓으로 만든 부분을 정정했고, ONT-005 note에는 이번에 일부러 건드리지
않은
GET /ontology/patches의 공개 심사자 신원 노출을 기록했습니다.검증 (로컬 CI parity, exact head
1a62855)pytestpytest tests/fuzzpython -m sdp.demo_smokealready_published: False2건 + revision 2증가; 기록 순서 반전 → entry digest 불일치;REVIEWED_SOURCES검사 제거 → 심사 없는 게시에 reviewer 날인 가능; 미분류 모듈 상태 추가 → 새 가드 실패; fixture 복원 1줄 삭제 → claim 테스트 실패자기 교정 1건: 이 라운드의 첫 순서-무관 측정에서 seed 11이 4건 실패로 나왔습니다.
원인은 제 자신이 그 백그라운드 실행 중에 역검증을 위해
conftest.py를 일시 변경한것이었고, 트리를 고정해 재측정하니 1069 passed입니다. 동결하지 않은 트리의 측정은
증거가 아니라는 것을 다시 적어 둡니다.
Summary
docs/product-technical-gap-baseline.md갱신입니다. #79 위에 쌓았고 base는docs/product-technical-gap-baseline입니다 — 같은 파일의 사본을 만들지 않기 위해 #79를 single writer로 두고 그 브랜치로 delta를 올립니다. 이 문장의 뒷부분("문서 전용이며 제품 코드·워크플로 변경은 없습니다")은 2026-10-05에 더 이상 사실이 아니게 되었습니다 —254054d가sso_oidc_adaptercontrol 구현을 더했습니다. 아래 "C. 2026-10-05 추가"를 보십시오.두 묶음입니다. 앞쪽은 2026-09-07 기준선 갱신과 repair finding 네 건(원래 이 PR의 내용), 뒤쪽은 2026-09-09에 이 문서 자체의 증거 오류를 찾아 정정한 것입니다. 뒤쪽이 병합 판단에 더 중요합니다.
A. 2026-09-09 정정 — 이 문서가 잘못 기록한 승인 근거
#81이 왜 Draft에 머무는지 추적하다가 문서 자체의 결함을 찾았습니다. 모든 정정은 reviews API 응답이나 잡 로그에 고정했고, 라벨·PR 본문·코멘트 표시는 근거로 쓰지 않았습니다.1. 병합 순서 1번(
#81)이 인용한 승인이 승인이 아닙니다. 문서는 "OpenCode exact-head APPROVE 확인(comment 5469292683) 뒤에만 PM squash"라고 지시했습니다. comment 5469292683은 seonghobae가@opencode-agent에게 승인을 요청한 코멘트입니다(Please APPROVE exact current head ce40bd8...).GET /pulls/81/reviews는opencode-agent[bot]리뷰를 어느 커밋에서도 0건 반환합니다 — 승인도,CHANGES_REQUESTED도 없습니다. 저장소 최우선 보안 PR을 존재하지 않는 receipt 위에서 squash하도록 지시하고 있었습니다.2. 병합 순서 3번(
#58)의 "이전 APPROVE"는 API 상태와 다릅니다. 현재 head에 붙은 리뷰의state는DISMISSED입니다. 본문 끝에만- Result: APPROVE가 적혀 있고, 본문이 주장하는 SHA는 그 리뷰 자신의commit_id와도 다릅니다.3. "승인만 있으면 바로 풀리는
#51·#35·#59세 건" 권고를 철회합니다. 근거였던 초록opencode-review가 검증을 하지 않는 세대입니다. 잡 본문 전체가echo한 줄입니다.95467498879957155005779556838884999291035386게다가 그 초록은 2026-08-17/18자로 측정일보다 3주 낡았습니다. 같은 main 계보인
#79는 2026-09-01 재스캔에서trivy-fs가 떨어졌습니다 —[HIGH] CVE-2026-69247 requirements.txt:125 - Package: cryptography(job99954799788).우선순위가 뒤집힙니다.
#81은 리뷰 게이트가 실제로 돌아 빠진 판정을 정확히 보고하는 유일한 PR이고,#35·#51·#59는 게이트가 돈 적이 없는 PR입니다.4.
#73head가 낡아 있었습니다 — 병합 순서와 PR 표가311668e, 실제는1681a7f. 새 head에opencode-agentCHANGES_REQUESTED가 붙어 있습니다.5. 판정을 읽는 규칙을 절로 고정했습니다: 요청 코멘트는 판정이 아니고, 리뷰 본문 산문도 판정이 아니며,
commit_id가 현재 head와 같아야 하고, 3~4초 초록은 no-op입니다.B. 실패 check를 끝까지 따라가면 포털이 소유한 것은 하나뿐입니다
#32·#73의 현재-headCHANGES_REQUESTED에는 코드 지적이 없습니다. 둘 다 "GitHub Checks have failed" 한 문장이고 미해결 리뷰 스레드는 0건입니다(#7331건·#323건 모두 resolved).#32의 두 건은 같은 head·같은 본문 중복입니다.각 실패 check의 잡 로그를 읽은 결과입니다.
opencode-review#81.github#2040/#2051/#2056CodeQL compatibility analysis#73VERDICT_STATE: pending— dispatch는 성공했으나 job을 재실행하는 wake가 오지 않음.github동일noema-review#73,#79deferred(429)로 표시한 라우트를 골라 366.4초 뒤 429contextual-orchestrator#971trivy-fs#32,#79외#81)noema-review(job102406024468)는 preflight가ready4건과deferred4건을 이미 구분해 두었는데도 그 429 라우트를 골랐습니다. 잡 경고가 스스로gateway owns repair/failover라고 적고 있어 호출자 재시도로 덮을 문제가 아닙니다.포털이 자기 저장소에서 고칠 수 있는 실패 check는
trivy-fs하나이고 single writer는#81입니다.C. 이 저장소만 유독 멈춰 있습니다
protected main은 2026-08-17
#66이후 병합이 없습니다(#83은 main이 아니라#73브랜치로 병합). main 기준 23일, 열린 PR 35건입니다. 같은 기간 조직은 멈춰 있지 않습니다 — 병합 누계 7,454건, 2026-09-09 당일에도 다수입니다.세션 범위 6개 저장소의 최근 병합 PR을 표본 조사한 결과 6건 중 0건이 병합된 head에
opencode-agentAPPROVED를 달고 있지 않았고, 4건은 리뷰 자체가 0건이었습니다. 거버넌스 owner인.github#2028도 병합 head는af7c6e3인데 유일한 판정은1d9c70b의CHANGES_REQUESTED였습니다.한계도 적었습니다 — base ref를 확인하지 않아 일부는 stack 수렴 병합일 수 있고, 저장소당 1건 표본입니다.
이것은 조치가 아니라 사람 유지보수자의 결정으로 적었습니다. 게이트를 낮추거나 자가 승인하거나 보호 규칙을 우회하는 조치는 이 문서의 다른 절과 동일하게 금지입니다. 측정이 더한 것은 대기 비용입니다 — 취약한 main으로 23일, 그 뒤에 35건.
D. 원래 이 PR의 내용 (2026-09-07 기준선 갱신)
#81Draft 전환,#90/#94정상 승계 확인(각각#72/#93이 delta 흡수),#92·#93·#96·#97·#99·#100신규 기록, head 이동(#7210096ce,#893b59d90),#75의 base가#73브랜치임을 명시.main에docs/adr/가 없는데 열린 PR 다섯 건이 같은 번호를 다른 결정으로 주장합니다. rename이지 redesign이 아닙니다.Accepted→ docs(adr): ADR 0001/0002 번호가 열린 PR 다섯 건에서 충돌합니다 (rename 필요) #103 코멘트.#73의 ADR 두 건만 미병합 상태에서Accepted를 주장합니다.#93/#100/#573자 파일 겹침 → ci(actions): isolate pull request concurrency #100 코멘트. 각자 상대에게 없는 delta가 있어 한쪽을 닫으면 손실이 납니다.#57은 Dependabot bump가 아닙니다 → chore(deps): bump github/codeql-action/upload-sarif from 4.36.3 to 4.37.6 #57 코멘트. 제목은 codeql-action bump인데 실제로는 커밋 18개·파일 18개이며 cryptography 49→50까지 포함합니다.#74참조 결함을ContextualWisdomLab/LineageWeave직접 링크로 정정.커밋 이력에 자기 정정이 두 번 있습니다
bc9169c는#90을 "복구 대상"으로 잘못 기록했고bd3593e에서 정정했습니다.87300ca는#32/#73의CHANGES_REQUESTED해소를 "포털 독립 작업"이라고 적었는데, 리뷰 본문을 열어 보니 해소할 코드 지적이 없어c5c1ad6에서 정정했습니다.Test plan
문서 전용입니다. 이 저장소에는 markdownlint 설정이 없고(
CLAUDE.md가 lint 명령을 만들지 말라고 명시), 이 파일은 기존에도 80자 초과 줄이 166건인 스타일이라 그대로 따랐습니다.인용한 근거는 모두 재현 가능합니다 — 잡 로그 id(
95467498879,95715500577,95568388849,99291035386,99954799788,102406319401,102406024468)와 리뷰 조회 한 줄입니다.Merge order
#79가 보안 unlock stack 뒤에서 squash되므로 이 PR도 그 뒤입니다.#79보다 앞세우지 마십시오.🤖 Generated with Claude Code
https://claude.ai/code/session_01QpbqAeggRho3fQA1gn34UY
D. 2026-10-14 추가 — 감사 큐 11건 소진 (보안 수리 9건 + 커버리지 1건)
C 절 이후 이 브랜치에 커밋 8건이 더 들어왔습니다. 전수는
docs/product-technical-gap-baseline.md의"감사 큐 소진 (1)~(8)" 절에 재현 명령·수치와 함께 있습니다. 모든 항목은 에이전트 보고를 믿지 않고
제가 직접 재현한 뒤 RED → 수리 → 로컬 검증 순으로 처리했습니다.
orchestrator: SQL validator 우회 3건FROM customer, secret_table→ 경고[]→unauthorized_table_reference.SELECT * INTO exfil FROM customer,$$...$$dollar-quote도 동일하게 통과하고 있었음authz:oidc-verify가 body의 trust anchor를 신뢰issuer/audience/jwks/role_map을 지정할 수 있어 자기 서명 토큰으로 platform-admin 취득 가능 → 네 필드 모두 400으로 거절, JWKS는 operator 설정에서만observability: 공개 manifest의 sink 자격증명https://sdp-ingest:s3cr3t-token@logs/push→target에sdp-ingest:s3cr3t-token@logs→logs. 비-http scheme은 query의 토큰까지 URL 통째로 노출catalog: discovery 표면의 governed 필드/browse/.../schema?user=nobody= 403인데/catalog/datasets/{id}= 200에 같은 스키마 +pii플래그 +postgresql://analytics.dw/customer+profile, 정책 결정·감사 기록 0건browse: preview PII 마스킹 우회semantic-glossary로 물으면customer_email='alice@example.com'평문. 지금은 row를 지정 데이터셋 선언 스키마에 투영evidence: tenant 귀속or "demo"fallback이 실제 tenant로 행을 오귀속.acme-corp데이터셋의 tenant 경계 위반 거부 감사 기록이demo아래 들어감 → 모든 분기에 실제 tenant stamp, 귀속 불가는UNRESOLVED_TENANT_IDdocker/entrypoint.shbreak로exec "$@"→ 스키마 없는 DB 상대로 서비스 기동. 실제 스크립트를sh로 돌려 재현(marker 생성 + exit 0) →exit 1observability: event loop 차단/health8개 동시 요청이 0.2초 sink 상대로 1.63초(= 8.1배, 완전 직렬화) → 0.23초(1.1배)tests: mutation 인가 가드 미검증검증 (로컬 CI parity, exact head)
PYTHONPATH=src pytestpytest tests/fuzz(Hypothesis)python -m sdp.demo_smokeready: true(exit 0)sh -n docker/entrypoint.sh리뷰어께 — 이 PR이 필요한 것
GitHub 쪽 게이트는 승인을 요구하지 않습니다(
Lock default branchruleset의required_approving_review_count = 0). 그런데도 열린 비-draft PR 7건 전부 APPROVED 0건이고,중앙 머지 스케줄러의
has_independent_current_head_approval은 현재 head에 작성자가 아닌APPROVED 리뷰를 요구하며 fail-closed입니다. 이 세션은 모든 PR을 같은 identity로 push·리뷰하므로
자기 PR을 승인할 수 없습니다(GitHub 422). 따라서 필요한 것은 독립 승인 1건입니다.
required check는 green이 될 수 없습니다. 계정 수준 Actions billing lock으로 hosted job이 dispatch
전에 거부됩니다(
runner="", 3~4초, 주석The job was not started because your account is locked due to a billing issue.). 위 검증 수치는 모두 CI parity 인터프리터(Python 3.12, hash-pinnedrequirements-test.txt)로 로컬에서 실행한 결과이며, CI 실행 증거가 아님을 분명히 적습니다.E. 2026-10-14 2차 — 독립 8-렌즈 감사 32건, 직접 재현 후 14건 추가 수리
D 절 이후 커밋 14건이 더 들어왔습니다. 이전 감사 큐(11건)를 소진한 뒤 독립적인 8개
렌즈로 다시 쓸었습니다(authz 공개 범위, tenant 격리, 자격증명 노출, 비신뢰 입력,
증빙 무결성, 가용성, 컨테이너·공급망, 계약 드리프트). 발견마다 서로 다른 관점의
반박자 3명을 붙였습니다. 전수 기록은
docs/product-technical-gap-baseline.md의"독립 다중 렌즈 감사" 절에 있습니다.
수리 (모두 제가 직접 재현한 증거)
111c0c2GET /catalog/search?q=HR→ 200에 타 tenantcritical자산의 id·title·owner: hr-owner·status. owner facet은 타 tenant owner를 집계. → 403 / scopebefb2a4GET /audit/events한 번에{'bank-a':1,'bank-b':1}. 읽기 경로 어느 함수에도 tenant 파라미터 없음 → 403 / tenant별 SQL 필터a19e014/graph/nodes/{id}인가 전무,pii: true공개. traversal이 공유 concept를 지나 depth 2에서 타 tenant 도달 → 403 / 노드·edge 필터44ff977postgresql://svc_user:S3cr3t-Pa55@payroll.acme.internal:5432/payroll을 미인증 호출자에게 → redaction + 게이트44ff977pg_catalog.sales,otherdb.otherschema.sales,JOIN finance.sales모두[]→ 전부unauthorized_table_reference3efd4eedata-admin(platform-admin 아님)이tenant_id=acme-bank로 심기 200, 같은 자산 PATCH/PREVIEW 403 → create도 403/profile게이트3efd4eerow_count: 1200000+ 컬럼quality_issues→ 게이트+감사20f8f7epii플래그 → 제거(저장 snapshot은 drift용으로 유지,changed_withheld_fields로 신호 보존)9f90c63changes/before/after세 군데 → 투영append_audit_event유실9f90c63audit_event_id는 반환(platform-admin 부여 기록 소멸) → fallback55743d0reviewed_by: biz-admin위조, 증빙 0건 → admin 인가 + 400 + 감사0f19c0bsearch_catalog2000토큰 5.10s / 5만 자 제안에 정책 평가 0건 / 없는 데이터셋 preview가 audit +5 policy +0 → 422·400·403·순서 교정4d9e3d3sdp_request_observations_total이 buffer 500 대비 25에서 포화 /KeyError('\n')→ 500 / paginationTypeError→ 500 /file://sink 절대경로·UNC 호스트명 / healthcheck가 상수/health/ 앱 포트 전 인터페이스 / 서빙 uid가/app소유7b1abeesecurity역할(platform-admin 없이)이 preview 200·2행 — 매트릭스는denied_actions로 공개 → 메타데이터/데이터 게이트 분리509a9cfb86dad8recent_requests가actor·tenant_id·데이터셋 id 박힌route를 공개 → 집계만리뷰어께 — 이 PR을 믿을 수 있는지 판단할 재료
방법론 한계(먼저 적습니다). 감사가 돌아가는 동안 확인된 항목을 먼저 고쳤기 때문에
반박 단계가 "이미 수리됨"을 이유로 refute하기 시작했습니다. 반박자들은 현재 트리를
정확히 읽었고, 따라서 그 워크플로의 최종 confirmed 목록은 실재성의 척도가 아니라 제
동시 수리의 부작용입니다. "32건 중 N건만 진짜"로 읽으면 틀립니다. 실제 판정 기준은
제가 직접 재현했는지입니다.
제 앞선 작업의 형제 경로 3건. #7
/profile, #5 validator, #13의file://sink는모두 같은 날 제가 한 수리의 다른 경로입니다. 특히
/profile은 제가 "형제 라우트11개를 쓸었다"고 적은 스윕이 놓친 것인데, 원인은 방금 정의한 governed 필드 목록과
대조하지 않고 리터럴 문자열 3개만 grep했기 때문입니다. 마커 검색은 경계 검증이
아닙니다.
제 결론을 정정한 1건.
3b490ce에서 저는 "RBAC 매트릭스는 일관되고 문제는 seed번들링뿐"이라고 결론 냈습니다. 틀렸습니다 —
_has_reader_role가security를 포함해역할 자체가 과다 부여했고, 기본 OIDC map
sdp-security → ['security']에는platform-admin이 없으므로 실제 federated security 검토자가 거부된다고 공개된 접근을
갖고 있었습니다.
7b1abee가 그것을 고칩니다.에이전트 보고 정정 4건.
buyer_handoff_ready상승 주장 → 거짓(False→False)./catalog/search93.4초 → HTTP로 재현 불가(httpx가 URL 길이로 거절, 실제 edge도request-line 한계). RBAC 진단 → 어긋남(위). 기준선 "345 passed" → 실제 337.
제가 고치지 않은 것 3건 (이유 포함).
tarball이 프록시 403이라 진짜 sha256을 계산할 수 없습니다. 검증하지 않은 해시는
핀이 아니라 핀의 외관이므로 적지 않았습니다.
security의platform-admin보유 여부 — 역할 과다 부여(명백한 오류)는 고쳤고seed 번들링은 매니페스트에 공개했습니다. 역할을 빼앗을지 매트릭스를 바꿀지는 제품
의도가 필요합니다.
네 connector로 확장하면 운영자가 secret reference 3개를 더 설정해야
ready_for_demo에 도달하므로 제품 결정입니다.검증 (로컬 CI parity, exact head)
PYTHONPATH=src pytestpytest tests/fuzz(Hypothesis)python -m sdp.demo_smokeready: true(exit 0)sh -n docker/entrypoint.shCI 실행 증거가 아님을 다시 분명히 적습니다. 계정 수준 Actions billing lock으로
hosted job이 dispatch 전에 거부됩니다(
runner="", 3~4초, 주석The job was not started because your account is locked due to a billing issue.). 위 수치는 전부 CI parity인터프리터로 로컬에서 실행한 결과입니다. 필요한 것은 여전히 독립 승인 1건이며,
이 세션은 모든 PR을 같은 identity로 push·리뷰하므로 자기 PR을 승인할 수 없습니다.
F. 2026-10-14 3차 — 데이터셋별 조회 경로 12개 일괄 범위 적용 (
6d37f59)E 절에서
/catalog/datasets/{id}상세 경로의 테넌트 범위를 고쳤습니다. 그 수정이형제 경로들에서 그대로 우회되고 있었습니다. 같은 접두사 아래 GET 라우트를
app.routes에서 12개 전부 열거해 외부 테넌트 데이터셋으로 측정했습니다./jsonld/lineage/related/join-candidates/validate,/semantic-validation,/schema-versions/schema-historyKeyError: 'no schema history found')수정 후: actor 없음 → 403, 타 테넌트 actor → 404, 12개 전부 누출 0건.
/schema-history의 500은 기존 결함입니다 — 저장소가 문서화한KeyError→404매핑을 어기고 있었고,
git stash로 이 변경 이전에도 재현되는 것을 확인했습니다.같은 결함을 세 번 놓친 원인을 구조로 고쳤습니다
D절·E절·F절이 같은 클래스의 결함입니다. 매번 수정은 맞았고 매번 형제 경로를
놓쳤습니다. 원인은 경로를 하나씩 손으로 점검한 것입니다. 그래서 이번에는 검사를
열거 기반으로 바꿨습니다 —
tests/test_catalog_tenant_scope.py가 경로 목록을app.routes에서 읽어서 돌므로, 다음에 추가되는 형제 경로는 제가 기억하지않아도 검사 대상이 됩니다. 도메인 예외가 500으로 새지 않는지도 같은 방식입니다.
테스트 측 변경은 두 종류이며 섞지 않았습니다
test_api.py호출부 5개(/semantic-validation,/schema-versions,/schema-history,/schema-diff,/lineage)는 거버닝 메타데이터의 비인증조회를 고정하고 있던 테스트입니다. actor를 넘기도록 고쳤습니다.
/profile,/{id}/audit)을 404로맞췄습니다. 이 접두사에서 고치려는 결함이 존재 노출이므로 403은 "있지만 네
것이 아니다"를 답해 닫으려던 oracle을 다시 엽니다.
/browse는 403을 유지합니다 —거기까지 가려면 이미 id를 알고 있고 거부 자체가 감사 증거가 됩니다.
검증 (로컬 CI parity, exact head
6d37f59)PYTHONPATH=src pytestpytest tests/fuzz(Hypothesis)python -m sdp.demo_smokeready: true(exit 0)CI 증거는 여전히 없습니다.
6d37f59의 required check 2건도 같은 계정 수준 billinglock으로 dispatch 전에 거부됐습니다(4초, 주석
The job was not started because your account is locked due to a billing issue.). 이 실패는 코드에 대한 정보를담고 있지 않으며, 재실행은 소유자만 가능합니다.
이 PR의 크기에 대해
이 PR은 이제 159 커밋입니다. 독립 승인 1건이 필요한데 PR이 커질수록 승인하기
어려워지는 역방향 압력이 있다는 점을 적어 둡니다. 다만 이 세션은 지정된 브랜치
하나에만 push할 수 있어 분할 선택지가 없습니다. 분할이 필요하다면 소유자 판단입니다.
G. 2026-10-14 자율 스윕 — 구매자 체감 갭 32건 발굴, 직접 재현 후 11건 수리
열린 PR 7건이 전부 승인 0건으로 막혀 있어, 루프 (2)항에 따라 제품 갭 발굴로
전환했습니다. 5개 차원(데모 경로 / 문서-구현 대조 / 증빙 정직성 / 거버넌스
불변식 / 통합 담당자 UX)에 독립 조사를 병렬로 돌려 후보 32건을 받고,
제가 현재 HEAD에서 직접 재현한 것만 고쳤습니다.
6d37f59/schema-history500)314df19created_at이 거부되던 문제08edbbf/enterprise/shacl-validation의 데이터셋별 리포트 범위화8ad8e8da15891ae17263adataset_ids누락 시 500 + 감사 원장의 라벨 없는 추정치f1d32370a1e2b7725e06a3a807f9/llm/search가 deny 결정을 달고 payload를 그대로 반환3eeab38user/purpose에 500287e684deafe9cae5c8cb가장 심각했던 2건
정책 엔진 fail-open (
725e06a).evaluate()가 분기 체인을 다 통과한 뒤마지막에
allow로 떨어졌습니다. 게다가 RBAC 매트릭스의action_catalog는verb_noun(patch_dataset)인데 엔진은 맨 동사(patch)로 일치시키므로공개된 action 이름 10개 전부가 미인식이었고, 매트릭스가 data-analyst에게
거부로 공개한 4개와
administer_tenants까지 모두allow로 떨어졌습니다.POST /policy/decision은 인증 없는 본문에서 action을 그대로 받으므로, 공개결정 API가 무엇에든 allow를 답하고 그 답을 증빙 원장에 기록했습니다.
draft→execute 워크플로 (
a15891a). drafter가 만든 SQL이 executor에서전량 거부됐습니다.
interval '90 day'가 인용 리터럴이라 같은 모듈의validate_sql_query가 거부하고, 게다가 하드코딩된created_at은 seed데이터셋 어디에도 없는 컬럼이었습니다. 저장소에 drafter도 executor도 있었지만
한쪽 출력을 다른 쪽에 넣는 테스트가 없어서 418개 테스트가 통과하는 동안
살아 있었습니다.
제 자신의 오류 3건을 적어 둡니다
/enterprise/connectors/{id}/probe에 테넌트 게이트를걸었더니 운영 콘솔이 actor 없이 그 경로를 호출해
Promise.all이 reject 되고badge가 "evidence load failed"가 됐습니다(
f1d3237). Chromium 실측으로수정 전후를 확인했습니다.
6d37f59에서/browse는 403을 유지하며 근거를"거기까지 가려면 이미 id를 알고 있다"고 적었습니다. 측정 결과 403/404 쌍
자체가 oracle이므로 그 근거는 틀렸고,
287e684에서 수정했습니다. 감사 증거는사유를 원장에 기록하고 클라이언트에는 구별 불가능한 답을 주어 둘 다 지켰습니다.
08edbbf의 열거 테스트는 심은 데이터셋이적합(conforming) 이어서 통과했는데, 그 라우트는 실패할 때만 이름을
싣습니다 — 라우트를 열거해도 fixture가 조건 분기에 닿지 않으면 스윕은
실행하지 않은 경로를 통과 처리합니다(
8ad8e8d). 또314df19의 테스트가draft-query의 200-with-error 형태를 고정하고 있었습니다(
deafe9c).같은 "형제 경로" 클래스가 이번 라운드에 5회 반복됐습니다
매번 수정은 맞았고 매번 형제를 놓쳤습니다. 그래서 검사를 전부 열거 기반으로
바꿨습니다 — 라우트 목록은
app.routes에서, 키워드 목록 reader는 AST에서,계약 기본값은 모델 introspection으로 읽습니다. 다만 5회차에서 배운 한계도
기록했습니다: URL 모양으로 열거하는 것은 "데이터셋을 해석하는 라우트"를
열거하는 것이 아닙니다 —
/browse/query는 데이터셋을 본문에서 받아 빠져나갔습니다.자율 스윕의 confirmed/refuted 수치는 신뢰하지 마십시오
검증 단계가 제가 이미 고친 항목들을 "Already fixed at the exact file/line cited",
"describes a superseded version of src/sdp/console.py"로 반박하기 시작했습니다.
제가 스윕 실행 중에 수리를 적용한 결과이므로, 그 워크플로의 confirmed 수치는
무엇이 실재했는지에 대한 척도가 아닙니다. 32건 전체를 보존한 뒤 검증을 중단하고
제가 직접 재현했습니다. 위 11건(커밋 14개)은 전부 제 재현 증거입니다.
수리하지 않고 남긴 것 (이유 포함)
GET /ontology/patches가 그 자체로 공개돼있어
requestor(사람 식별자)가 미인증 노출됩니다. 공유 어휘의 제안 큐를공개로 둘지는 제품 결정이며 두 표면 간 불일치가 아니므로 바꾸지 않았습니다.
security역할의platform-admin보유. 매트릭스security행이7/10 셀에서 엔진과 어긋나는데, 원인은 seed subject의 역할 묶음입니다.
725e06a의 테스트가 이 사실 자체를 단정하므로 seed가 좁혀지면 테스트가알려줍니다.
/browse/query400 vs/browse/{id}/schema404). 각 라우트 안에서는 구별되지 않으므로 노출은 없고, 응답 형태 차이에서
오는 외형적 비일관성입니다.
제품 결정이거나, 아직 손대지 않았습니다.
/enterprise/demo-plan의"빈 플랜" 주장은 제 프로브가 잘못된 키를 봤습니다(
steps가 아님). 확인전까지 confirmed로 올리지 않습니다.
검증 (로컬 CI parity, exact head
ae5c8cb)PYTHONPATH=src pytestpytest tests/fuzzpython -m sdp.demo_smokeCI 증거는 여전히 없습니다. 이번 라운드에 푸시한 14개 head 전부의 required
check가 같은 계정 수준 billing lock으로 dispatch 전에 거부됐습니다
(2~4초, 주석
The job was not started because your account is locked due to a billing issue.). 이 실패는 코드에 대한 정보를 담고 있지 않고, 재실행은 소유자만가능합니다. 알림 40건이 전부 이 패턴과 CodeRabbit "Bot user detected" 건너뜀
편집이었습니다.
필요한 것은 변하지 않았습니다 — 독립 승인 1건, 그리고 Actions billing 해제.
G 절 보충 — 같은 라운드에서 6건 더 (
caa2670…12e70fc)caa2670paid_pilot_ready가 반증 불가능 — 하드코딩 리터럴에서 유도6794e0dbabd753eb410f012e70fcpaid_pilot_ready(caa2670). 네 integration의status리터럴이 전부"implemented"이고 매니페스트 함수는 런타임 상태를 하나도 읽지 않았습니다.설정이 전무하고 자기 proof endpoint 3개가 403인 상태에서도
true+ blockers 0."코드가 구현됐다"와 "이 배포가 설정됐다"는 다른 주장이므로 둘 다 보존하고
플래그가 양쪽을 요구하게 했습니다. 테스트가 선언된 변수를 하나씩 빼면서
반증 가능성을 전수 확인합니다.
온톨로지 루프 (
babd753). 개념 저장소가 두 개였고 서로 만나지않았습니다 —
POST /ontology/concepts는 graph store에 쓰고, search·resolve·concept·concepts는 모듈 vocabulary를 읽었습니다. upsert가 200을 반환한 직후
/ontology/search가 count=0이었습니다. 승인은 그 쓰기 경로를 아예 호출하지않았습니다. 지금은 승인이 같은 registrar를 호출하며, 이미 발행된 정의는
덮어쓰지 않습니다.
그래프 미러링 (
eb410f0).seed_store()가 시작 시점에만 돌아서 온보딩한데이터셋은 그래프에 전혀 없었습니다(create 후 nodes 26→26, 노드 404). README가
graph traversal과 semantic search를 대표 기능으로 내세우는데 구매자 자신의
데이터가 양쪽에 보이지 않았습니다. 수정 후 26→30 nodes, 노드 200, deprecate 시
status 속성 갱신. 그리고 이 변경이
/graph/nodes/{id}를 새로 도달 가능하게만든 직후 "여기도 같은 oracle인가"를 출하 전에 물어 같은 커밋에서 닫았습니다
(여섯 번째 반복이지만 처음으로 사후가 아니라 사전에 발견).
세 번 "테스트가 없어서 살아남았다"가 반복됩니다
a15891a: drafter와 executor를 연결한 테스트가 없었습니다 (418 통과 중 결함)eb410f0: 생성된 데이터셋의 그래프 도달을 본 테스트가 없었습니다12e70fc: JSON-LD export의 형태를 본 테스트가 없었습니다 (498 통과 중 결함)세 경우 모두 "수정 전후 모두 기존 테스트 전부 통과"입니다. 각 커밋에
역검증(해당 수정만 되돌렸을 때 새 테스트가 실패함)을 기록했습니다.
최종 검증 (exact head
12e70fc, working tree clean)PYTHONPATH=src pytestpytest tests/fuzzpython -m sdp.demo_smokeready: true/catalog/datasets/{id}하위 GET 12개이 라운드 수리 총 19 커밋 / 결함 17건. CI 증거는 여전히 없습니다 — 푸시한
head 전부가 계정 billing lock으로 dispatch 전 거부(2~4초)되었고, 알림 40건이
모두 그 패턴과 CodeRabbit 건너뜀 편집이었습니다. 필요한 것은 독립 승인 1건과
Actions billing 해제입니다.
추가 라운드 (head
22a7698) — 자기 리뷰 결과와 기계가 읽는 계약이 라운드는 구조화된 자기 리뷰로 시작했습니다. 각 주장을 재현 없이는 보고하지
않도록 하고, 재현된 것만 수정했습니다. 결과적으로 제가 앞선 라운드에서 만든
회귀가 가장 큰 비중을 차지합니다.
긴 목록 대신:
CHANGELOG.md이 브랜치는 베이스 대비 164개 커밋 / 73개 파일을 바꿉니다. 리뷰어가 읽을 수 있는
지도가 없다는 것 자체가 갭이었으므로
CHANGELOG.md를 추가했습니다 — 네 개 절(테넌트 경계와 존재 oracle / 문서화된 워크플로가 실제로 동작하지 않던 것들 /
정직한 측정 / 기계가 읽는 계약)과 Known limitations 절을 포함합니다.
릴리즈가 아닙니다:
[Unreleased]에만 기록하고 버전도 올리지 않았습니다.이 라운드에 고친 것
d9e1731/policy/decision측면 채널(사전 결함), 메모리 상한이 실제로는 상한이 아니었던 문제dcf41b8allowed주장, 감사 행에 테넌트 없음e6db3890226b0acc6bf9bpaid_pilot_ready를 아무것도 읽지 않는 변수로 충족할 수 있던 문제797e4f722a7698CHANGELOG.md측정 몇 가지 (전문은 각 커밋 메시지에)
canonicalize('고객')이 권한 있는 upsert 한 번으로'침입개념'이 됐습니다.catalog._AUDIT_LOG2000(상한),evidence._AUDIT_EVENT_LOG2600.decisions +2 audits +2, 같은 action에 서로 다른 결과./search/semantic최상위 결과로 복귀.dcat:distribution이 conforming 프로세서(pyld 3.3.0)에서[{}]로 확장 — access URL도 format도 없음.SDP_EVIDENCE_RETENTION_DAYS='banana'로 paid-pilot 게이트 통과, 소비자는src/어디에도 없음.이 라운드가 남긴 자기 정정 4건
/browse에서는 참, 카탈로그 계열에서는 거짓. 그 격차는0226b0a에서 닫았고, 그 수정이 원장을 통해 oracle을 다시 열었다는 것도 커밋 전에 측정으로 잡아 교체했습니다./policy/decision측면 채널은 사전 결함입니다 — 제가 도입한 것이 아닙니다.검증 (exact head
22a7698)PYTHONPATH=src pytestpytest tests/fuzzpython -m sdp.demo_smokeready: true?user=admin양쪽 배지·패널 실측GET /openapi.json$ref0건블로커는 그대로입니다
필요한 것은 독립 승인 1건과 Actions billing 해제이며 둘 다 소유자만 할 수
있습니다. 자기 승인은 하지 않습니다. head
0226b0a/22a7698의 체크 실패 주석을원본에서 재확인했고 전부
The job was not started because your account is locked due to a billing issue.입니다. 이 사실은 이미 #102에 두 번(2026-10-03, 2026-10-05) 기록했으므로 새 코멘트를
달지 않고, PR을 계속 지켜보며 한 시간 뒤 상태를 다시 확인합니다.
3라운드 (exact head
78d51b0) — "게시된 숫자가 그것을 만든 코드와 일치해야 한다"2라운드 이후 8건을 더 수리했습니다. 전부 측정 후 수리했고, 각 수리는 되돌려
해당 테스트가 실패하는지 역검증했습니다.
로그 sink: 설정되었다고 보고하면서 한 건도 보내지 않았습니다
urllib이sdp-ingest:TOKEN@logs.example.net에 접속 →InvalidURL: nonnumeric port, 매 export 실패. manifest는configured: true, target: logs.example.netAuthorization헤더로 분리(Basic/Bearer), host 없음·port 비숫자도configured: false+invalid_configuration_EXPORT_ERRORS에 프로세스 수명 내내 보존SDP_ALERT_WEBHOOK_URLrequired_environment에서 paid-pilot gate를 통제. 읽는 코드는 "설정 여부" 보고 한 곳뿐이고 이 URL로 POST하는 코드도, 게시된 alert 조건을 평가하는 코드도 없음declared_unimplemented_environment로 이동, alert 규칙은 유지하되alert_evaluation: declared_not_evaluated/evaluated: false/alert_webhook_routing: declared_unimplemented표시마지막 항목은 기존 기계적 검사
(
test_every_required_variable_has_a_consumer)의 한계를 드러냈습니다 — 소비자가존재했고(자기 존재 여부만 읽는 소비자) 검사를 통과했습니다. 능력에 대한 증빙은 이름
검색이 아니라 전용 테스트가 담당합니다.
게시된 SHACL shape catalog가 평가되지 않았습니다
카탈로그가 양방향으로 틀렸습니다(검사하지 않는 property를 게시, 검사하는 property를
미게시). 이제 게시된 모든 property가 그것을 선언한 항목에 의해 평가되고(violation path는
distributions[0].format처럼 색인),DatasetShape는REQUIRED_METADATA_FIELDS에서유도되며, "승인 mapping 최소 1건"은
min_approved_countcardinality로 선언됩니다.shacl_compatible은 하드코딩true→ "게시된 모든 shape가 평가되는가"로 유도되고,SHACL 엔진이 돌지 않는다는 사실(
pyshacl은 의존성 아님)을validation_engine/shacl_engine_executed로 함께 게시합니다. 시드는 이미 모든 shape를 만족하므로validation_pass_rate는 1.0에서 움직이지 않았습니다 — gate가 더 많이 측정하고 숫자는그대로입니다.
ontology_mapping_coverage가 커버리지를 줄이면 올라갔습니다승인 mapping / 전체 mapping이었고, 자기 KPI 정의("glossary terms 중 매핑된 비율")와분모가 달랐습니다. 이 값은
demo_smoke의ready와 saleability gate를 0.7 기준으로통제합니다.
그 분모가 가린 두 가지: mapping이 전혀 없는 glossary 개념
이탈 고객(분모에 없어서 유일한실제 갭이 보이지 않음), 그리고 ontology가 정의하지 않은 개념을 가리키는 mapping 3건
(
고객 이탈·전환·캠페인; 2건이 approved여서 미정의 개념에 대한 승인 mapping이분자였습니다). 세 수치를 각자의 이름으로 게시합니다: coverage(0.8),
ontology_mapping_approval_rate(0.917),unmapped_glossary_concepts,unregistered_mapping_concept_count. KPI 정의가 자기 guardrail과 모순이던 부분은 숫자를느슨하게 맞추는 대신 정의를 엄격한 쪽으로 정정했습니다. 0.8 ≥ 0.7이므로 gate와
ready는불변 — 결함 수정이고 제품 결정이 아닙니다.
connector control 판정이 사전 멤버십 동어반복이었습니다
implemented = bool(_IMPLEMENTED_PROOF.get(control, []))— "구현되었는가"를 "이름이 같은파일의 손으로 쓴 dict의 키인가"로 답했습니다. 유효한 커넥터×데이터셋 5쌍, 필수 control
11종 전수 측정: dict에 11/11이 있어
planned가 도달 불가능했고, 판정을 움직일 수있는 것은
credential_vault의 secret 확인 하나뿐이었습니다. control 11개가"implemented"라고 세고 있던 구매자는 주장 11개를 세고 있었습니다.
각 control의 효과를 실제로 검증하는 것은 결함 수정이 아니라 별도 프로젝트이므로, 주장을
거짓이 될 수 있게 만들고 강도를 말하게 했습니다: control은 선언한 proof endpoint를 이 빌드가
전부 서비스할 때 implemented이고(라우트가 사라지면
planned+gap_reason), 행마다verification(declared_only/proof_route_served/runtime_probe_verified)과control_verification_counts,verdict_basis를 게시합니다. 여기서 나오는 정직한 숫자:기본 데모 배포에서 runtime 검증 control은 0개입니다.
POST /enterprise/auth/oidc-preview— 미인증 oracle + 증폭기group 이름을 하나씩 추측해 배포의 group→role 정책 전체와 유효한 tenant scope를 읽을 수
있었습니다. trust anchor를 request body에서 거절한 이유, 이 라우트의 원장 기록에 actor를
요구한 이유와 같은 범주입니다. 수정은 좁습니다: 호출자가 보낸
role_map과 저장소 상수인공개 기본 map은 그대로 두고, actor 없는 호출에서
SDP_OIDC_GROUP_ROLE_MAP만 쓰지않습니다(첫 초안은 기본 map까지 숨겨 데모 경로를 깼고, 유지된 테스트가 잡았습니다). 이제
매핑된 group과 매핑되지 않은 group의 응답은 이름을 제외하면 동일합니다.
actor_context_verified: false와role_map_source도 함께 게시합니다.같은 라우트가 증폭기였습니다 — claim 목록이 개수·길이 무제한이고 group 이름이 두 번
반환되었습니다:
상한의 첫 구현은 권한 결과를 조용히 바꿨습니다: 길이 제한을
_claim_values안에 두어매칭에 쓰이는 값까지 잘랐는데 map의 키는 자르지 않으므로, 300자 group 이름이 자기 map 항목과
일치하지 않아
roles: []가 되었습니다(이전["data-analyst"]). 부여가 아니라 보류이므로fail-closed이지만 조용했습니다. 매칭은 실제 값으로 하고 내보낼 때만 자릅니다. 개수 상한은
매칭에도 적용되며
withheld_group_count로 선언합니다.oidc-verify에서도 같은 두 카운터를게시합니다 — 그쪽에서는 개수 상한이 권한 결과를 바꿀 수 있기 때문입니다(fail-closed).
검증 (exact head
78d51b0)PYTHONPATH=src pytestpytest tests/fuzzpython -m sdp.demo_smokeready: true블로커는 그대로입니다 (2026-10-14T12:27Z 원본 재확인)
open,mergeable_state: unstable, APPROVED 리뷰 0건(리뷰 3건 전부COMMENTED, 그마저 stale head).actions/runs?status=success&per_page=100의 비-Dependabot 최신 성공은여전히 2026-10-02T09:03:05Z. Dependabot은 GitHub 자체 할당량으로 돌아
2026-10-14T03:34:20Z에 성공했으나 이는 계정 잠금 해제의 증거가 아닙니다.
The job was not started because your account is locked due to a billing issue.(런 소요 6초 — 디스패치 전 거부).필요한 것은 독립 승인 1건과 Actions billing 해제이며 둘 다 소유자만 할 수
있습니다. 자기 승인은 하지 않습니다. 이 사실은 이미 #102에 두 번(2026-10-03, 2026-10-05)
기록했으므로 새 코멘트를 달지 않고, PR을 계속 지켜보며 한 시간 뒤 상태를 다시 확인합니다.
4라운드 (exact head
51c6fdd) — 적대적 스윕 2회분, 확인 15건 전수 처리두 번의 적대적 스윕(43 에이전트 / 10 에이전트)이 보고한 것 중 적대적 반증을 통과한 15건을
전부 처리했습니다. 6건은 이미 3라운드에서 고쳐져 있었고, 7건을 새로 고치고, 2건은 재현되지
않아 고치지 않고 이유를 기록했습니다. 모든 수리는 제 손으로 HEAD에서 재측정한 뒤 적용하고,
되돌려 해당 테스트가 실패하는지 역검증했습니다.
거버넌스 경계 — 한쪽 방향으로만 유지되던 것들
'external-export'·'EXTERNAL-EXPORT'→ 403,' external-export'·'external-export '·'external-export\t'→ 200 (preview·governed query 양쪽). 기록된 결정은effect: allow— 증빙 원장이 그 반출을 정책 승인으로 증명했습니다'Analysis'·' analysis'에서 조용히 사라짐(거부 아님, obligation 누락)GET /ontology/term/{term}/graph가 미인증으로 전 테넌트 dataset id 제공/ontology/concept/{c}→dataset_ids: []+withheld 4,POST /llm/search→ 403). 이 payload의 세 번째 유출 경로data-admin이zeta-secret:customer_email을 읽으면 404, 쓰면 200,pii: true → false.create는 소유 테넌트를 카탈로그에서 해석할 수 없는 유일한 분기이고target_tenant_id를 받지 못했습니다/policy/decisions가 외부 id 0행 / 없는 id 1행. 형제 라우트는 모두evaluate_dataset_scope_refusal을 쓰는데 preview만 빠진 놓친 호출자analyst가subject: external-analyst로 질의 → 원장에subject: external-analyst, effect: deny, reason: tenant boundary denied. 그런 시도는 없었고 행을 만든 호출자 이름은 어디에도 없었습니다purpose는 진입점에서 한 번 정규화해 두 분기가 다시 어긋나지 않게 했고, 그래프 쓰기는 노드와
엣지 양 끝점을 검사하며 거부를 감사합니다. 남은 구별(없는 데이터셋 접두사는 허용 / 외부
테넌트는 403)은
admin역할이 필요해 익명·analyst로 닫은 oracle보다 좁으며, 닫지 않은 이유와함께 테스트에 명시했습니다.
dataset_ids를 반환하는 라우트는 게시된 응답 계약에서 열거해네 번째 호출자를 기계적으로 잡습니다.
SQL 게이트 — 증명할 수 없는 것을 인증하고 있었습니다
토큰이
[A-Za-z_][\w.]*여서$와 0x80 이상에서 끊기고 뒷부분을 alias로 버렸습니다.PostgreSQL 쪽은 추론이 아니라 측정입니다: 이 발견의 독립 검증이 일회용 UTF-8 클러스터를 띄워
customer·customer$pii_raw·customer×pii_raw를 세 개의 서로 다른 테이블로 만들고SELECT count(*), max(secret) FROM customer$pii_raw→1 | ssn-123-45-6789,SELECT count(*) FROM customer→0을 확인했습니다. 즉 validator가customer로 인증한 참조는실제로 다른 행을 가진 다른 테이블입니다. 함수 쪽은 PRD **BRW-005(P0)**가 요구하는 차단이
없었습니다 — deny-list는 무한집합이라 증명할 수 없으므로 allowlist
(
count·sum·avg·min·max— drafter가 실제로 내보내는 것 + 표준 집계)로 막습니다.drafter는 자기 validator가 거부하는 SQL을 내보냈습니다(이 부류 세 번째): source table 이름이
절 키워드(
order등 24개 전수 확인)와 겹치면 draft 200 → execute 400unauthorized_table_reference. 명확한 사유로 거부하고, 완성된 질의를 자기 validator로 자체검사해 네 번째 원인을 보고 없이 잡습니다. 선언 컬럼명을 조용히 바꾸고 원래 이름을 보고하던
문제는 변환을 유지하되(인용 식별자는 이 게이트가
literal_values_not_allowed로 거부하므로 실행불가)
emitted_identifiers로 보고하고 모호한 변환은 거부합니다.게시된 수치 — 페이지 크기, 도달 불가능한 마이그레이션, 설정할 수 없는 필드
/metrics500,/enterprise/evidence-pack500,/enterprise/observability500.sdp_request_observations_total은 이미 고쳤는데 감사·정책 gauge가 남아 있었습니다. 다음 사례는 AST 검사가 잡습니다_add_tenant_column이 컬럼 존재 시 자기 backfill 전에 반환. ALTER만 하는 형태로 먼저 출시되었으므로 그 빌드로 업그레이드된 SQLite 원장은 전부 영원히 미복구 — 측정: payload에tenant_id: demo가 있는데 열어도['unresolved']license를 어떤 API로도 설정 불가CC-BY-4.0→internal, patchMIT→internal, 시드 전체internal. 노출만으로는 부족 —register_dataset이 필드를 손으로 나열해 새 필드를 또 무시metadata_completeness:>=90 percent+status: implemented인데 0.90 비교가 어디에도 없음?user=external-analyst(출하된 유효 actor, 테넌트가 해당 데이터셋 비소유): probe 404(경계 동작), 나머지 200인데 badge는evidence load failed: connectors콘솔 수리에서 제 첫 시도가 상태 allow-list를 재도입했고, 그것을 금지하는 제 예전 테스트가
잡았습니다. 범위 비교로 바꿨고, 그 테스트도 "하나의 표현식"이 아니라 "규칙"을 단언하도록
다시 썼습니다. Chromium 실측: signed-out →
signed-out view - add ?user=,external-analyst →
refused for this actor: connectors, analyst/admin →ready for pilot review.재현되지 않아 고치지 않은 2건 (이유 기록)
/validatevs/catalog/datasets/{id}완성도 불일치(0.909 vs 1.0) —REQUIRED_METADATA_FIELDS단일 출처화로 이미 닫혀 둘 다 1.0입니다.
/ontology/resolve가 미정의 용어에 minted URI를 준다 — 실제로는resolved: []입니다. 남은것은 substring 매치에 score 1.0을 주는 점이고, 이는 결함이 아니라 **스코어링 휴리스틱(제품
결정)**이므로 바꾸지 않았습니다.
그 밖에 이 라운드에서 닫은 것
object/additionalProperties)였습니다. JSON GET 38개에 스키마를 게시하는데, 손으로 쓰지않고 라우트를 실행해 유도하고 테스트가 실제 응답과 대조합니다 — 그 테스트가 제 유도 코드의
narrowing 버그(
null관측 필드에string게시)를 바로 잡았습니다.GET /metrics는application/json으로 문서화돼 있어 생성된 클라이언트가 Prometheus 텍스트를 JSON으로파싱했을 것이므로 실제 content type으로 정정했습니다.
검증 (exact head
51c6fdd)PYTHONPATH=src pytestpytest tests/fuzzpython -m sdp.demo_smokeready: trueGET /openapi.json블로커는 그대로입니다 (2026-10-14T13:51Z 원본 재확인)
open,mergeable_state: unstable, APPROVED 리뷰 0건.actions/runs?status=success&per_page=100의 비-Dependabot 최신 성공은 여전히2026-10-02T09:03:05Z. Dependabot은 GitHub 자체 할당량으로 2026-10-14T03:34:20Z에
성공했으나 계정 잠금 해제의 증거가 아닙니다.
The job was not started because your account is locked due to a billing issue.필요한 것은 독립 승인 1건과 Actions billing 해제이며 둘 다 소유자만 할 수 있습니다.
자기 승인은 하지 않습니다. 이 사실은 이미 #102에 두 번(2026-10-03, 2026-10-05) 기록했으므로
새 코멘트를 달지 않고, PR을 계속 지켜보며 한 시간 뒤 상태를 다시 확인합니다.
Generated by Claude Code