Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
c3def46
fix(orchestrator): fail closed outside explicit development mode
seonghobae Aug 9, 2026
6525970
test(orchestrator): prove production never returns deterministic fake…
seonghobae Aug 9, 2026
02d1bf6
test(coverage): include orchestrator production boundary
seonghobae Aug 9, 2026
2b02f23
docs(orchestrator): define fail-closed learned-coordination boundary
seonghobae Aug 9, 2026
eb9435e
ci(orchestrator): verify fail-closed production behavior once
seonghobae Aug 9, 2026
cc60a85
docs(changelog): record orchestrator production boundary
seonghobae Aug 9, 2026
bdc81fa
ci: remove completed orchestrator one-shot writer
seonghobae Aug 9, 2026
af69b5f
test(orchestrator): reject oversized provider responses
seonghobae Aug 14, 2026
6281964
fix(orchestrator): bound provider response bodies
seonghobae Aug 14, 2026
75f73e4
merge develop: preserve security hardening in orchestrator PR
seonghobae Aug 14, 2026
cc76f59
test(orchestrator): enforce streaming response byte budget
seonghobae Aug 14, 2026
7ea77f0
fix(orchestrator): bound provider response while streaming
seonghobae Aug 14, 2026
c5c890e
docs(orchestrator): record streaming response bound
seonghobae Aug 14, 2026
1e6916a
test(orchestrator): expect explicit development adapter
seonghobae Aug 14, 2026
261cb5d
test(orchestrator): require hardened cost attribution
seonghobae Aug 14, 2026
9ba9d6e
test(orchestrator): register attribution regression
seonghobae Aug 14, 2026
6b51afa
test(api): bind AI cost attribution to project tenant
seonghobae Aug 14, 2026
046e5ca
test(api): register tenant attribution regression
seonghobae Aug 14, 2026
b516e9b
test(orchestrator): bound attribution metadata
seonghobae Aug 14, 2026
b01ebbc
feat(orchestrator): forward bounded cost attribution
seonghobae Aug 14, 2026
855e89c
feat(orchestrator): bind cost attribution to project tenant
seonghobae Aug 14, 2026
4a806b5
test(orchestrator): cover residual production boundary branches
seonghobae Aug 14, 2026
d648c45
test(orchestrator): register residual branch coverage suite
seonghobae Aug 14, 2026
766d577
test(orchestrator): cover IPv6 loopback transport
seonghobae Aug 14, 2026
2a81fb4
fix(orchestrator): recognize IPv6 loopback hostname
seonghobae Aug 14, 2026
d072ce1
test(orchestrator): carry forward parent branch coverage
seonghobae Aug 15, 2026
81ab288
test(orchestrator): register inherited coverage suite
seonghobae Aug 15, 2026
12fc3ad
fix(orchestrator): carry forward IPv6 transport boundary
seonghobae Aug 15, 2026
e1d6c72
merge: refresh attribution stack on current orchestrator parent
seonghobae Aug 15, 2026
bd9bfe3
test(orchestrator): reject ambiguous provider endpoint configuration
seonghobae Aug 15, 2026
268536f
fix(orchestrator): require a canonical provider origin
seonghobae Aug 15, 2026
8c150a7
docs(orchestrator): document canonical provider origin
seonghobae Aug 15, 2026
46723a0
test(orchestrator): classify provider rejection before response body
seonghobae Aug 15, 2026
1578bf3
fix(orchestrator): classify provider rejection before body parsing
seonghobae Aug 15, 2026
b2f9639
test(api): isolate orchestrator mock from inherited provider config
seonghobae Aug 15, 2026
759de58
chore(orchestrator): refresh attribution stack on hardened parent
seonghobae Aug 15, 2026
e482891
test(orchestrator): cover non-JSON response branch
seonghobae Aug 15, 2026
7b94e5f
test(orchestrator): inherit parent coverage repair
seonghobae Aug 15, 2026
650c7f6
chore(stack): record current orchestrator parent
seonghobae Aug 15, 2026
d57572a
test(orchestrator): require cancellation of rejected bodies
seonghobae Aug 15, 2026
dd85ee0
fix(orchestrator): cancel rejected provider response bodies
seonghobae Aug 15, 2026
c6a13c0
fix(orchestrator): preserve parent rejection cleanup in attribution s…
seonghobae Aug 15, 2026
ef8b807
test(orchestrator): preserve parent rejection cleanup regressions
seonghobae Aug 15, 2026
f86ff8d
merge(orchestrator): reconcile attribution stack with hardened parent
seonghobae Aug 15, 2026
dda097a
merge(orchestrator): reconcile attribution with protected develop
seonghobae Aug 16, 2026
9f9ce0e
test(orchestrator): reject non-scalar attribution values
seonghobae Aug 16, 2026
69b4439
fix(orchestrator): reject malformed attribution labels
seonghobae Aug 16, 2026
59c109f
merge(orchestrator): reconcile protected XML import baseline
seonghobae Aug 16, 2026
b79b503
fix(stack): preserve protected toast tests in attribution branch
seonghobae Aug 17, 2026
c051470
merge: reconcile orchestrator attribution with current develop
seonghobae Aug 17, 2026
0a03887
test(orchestrator): require prototype-free attribution map
seonghobae Aug 17, 2026
4714670
fix(orchestrator): isolate attribution map prototype
seonghobae Aug 17, 2026
dc71cdf
test(orchestrator): require adaptive mode with hardened request
seonghobae Aug 17, 2026
5510058
test(orchestrator): preserve attribution under adaptive mode
seonghobae Aug 17, 2026
bd88785
feat(orchestrator): compose adaptive routing with cost attribution
seonghobae Aug 17, 2026
5a18f0b
docs(orchestrator): document adaptive attributed requests
seonghobae Aug 17, 2026
a728d87
docs(doctoring): trace adaptive attributed orchestration
seonghobae Aug 17, 2026
3069978
docs(changelog): record adaptive attributed orchestration
seonghobae Aug 17, 2026
36fa982
merge(develop): reconcile orchestration attribution with OpenCode config
seonghobae Aug 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Switched the repository-local OpenCode development configuration from GitHub
Models to an NVIDIA NIM-only candidate set while preserving organization-level
review-workflow ownership in `ContextualWisdomLab/.github`.
- Production planning-analysis requests now combine tenant-bound, server-derived
contextual-orchestrator cost attribution with explicit `auto` orchestration
mode, delegating provider/model/topology policy to the shared service without
weakening ScopeWeave's authenticated, fail-closed transport or response
boundary controls.
- Accepted XML whitespace before exact Microsoft Project element delimiters
while preserving the linear, regex-free import scanner and rejecting
attributes, longer names, non-XML whitespace, nested unmatched blocks, and
Expand Down
41 changes: 41 additions & 0 deletions docs/doctoring/contextual-orchestrator-auto-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Contextual-orchestrator adaptive planning default

## Status

Active pull-request evidence. This record does not describe protected `develop` until the owning pull request is integrated.

## Decision boundary

ScopeWeave owns the meaning, authorization, cost attribution, and presentation of a planning-analysis request. The shared `contextual-orchestrator` service owns provider/model selection and the depth/topology of execution. Production ScopeWeave requests therefore send `orchestration_mode: "auto"` explicitly instead of relying on an implicit gateway default or selecting `route`/`conduct` locally.

The binding dependency evidence verified for this slice is protected `ContextualWisdomLab/contextual-orchestrator` `main` commit `6841b71935e0b7cb98fb52bcb4709cc5100c8d87`. At that revision, `/v1/chat/completions` accepts `orchestration_mode`, permits `auto`, `route`, and `conduct`, accepts bounded attribution metadata, and routes execution through the orchestrator rather than treating the request model label as a provider lock.

This decision does **not** promise a specific provider, model, worker count, topology, verifier strategy, or cost heuristic. Those remain shared-service policy and may evolve behind its versioned contract.

## Attribution and tenant authority

Authenticated project AI briefings attach `service=scopeweave` and the project organization as `account` only after membership-scoped project authorization. Browser request fields cannot select another tenant's accounting identity. The client forwards only supported attribution dimensions, accepts bounded strings or finite numeric identifiers, uses a prototype-free validated map, and omits empty attribution. These labels are accounting metadata and never grant execution-provider or model-selection authority.

## Security and standalone behavior

The change preserves the protected ScopeWeave orchestrator boundary: authenticated canonical provider origin, HTTPS outside explicit loopback development, bounded messages, 120-second request timeout, bounded streamed provider responses, sanitized failures, and deterministic text only under explicit `SCOPEWEAVE_DEV=1` development mode. No provider credential or caller-controlled execution policy is added.

## TDD and overlap-convergence evidence

The adaptive-mode work originally existed separately in PR #529 while cost attribution occupied the same production request-body boundary in PR #496. Keeping both as independent roots created a concrete future regression risk: whichever branch integrated second could erase the other request field. The older attribution owner was therefore made the canonical combined boundary rather than allowing two competing implementations.

On the canonical branch, test-only commits `dc71cdff9dc258b8f196c35d9b92c1542e869043` and `5510058ae7437ede44fb7a7fd94351ac7f7d6b14` first require `orchestration_mode: "auto"` both on ordinary hardened requests and while tenant-bound attribution is present or omitted. Source commit `bd8878591bfa74b67ae2a36b122513d2c41e376f` then composes adaptive routing with the existing sanitized attribution request. Exact-current-head hosted evidence remains authoritative; predecessor checks are not reused.

## Rollback

Rollback of adaptive mode removes the explicit `orchestration_mode` field and its matching regression/documentation while preserving the tenant-bound attribution and hardened transport. Rollback of attribution separately removes only the attribution call-site, sanitizer, and attribution regressions. Neither rollback may restore stale pre-hardening orchestrator source or a self-modifying workflow.

## APA 7th references

Contextual Wisdom Lab. (2026). *contextual-orchestrator* (Commit 6841b71935e0b7cb98fb52bcb4709cc5100c8d87) [Computer software]. GitHub.

Nielsen, S., Cetin, E., Schwendeman, P., Sun, Q., Xu, J., & Tang, Y. (2025). *Learning to orchestrate agents in natural language with the Conductor*. arXiv. https://doi.org/10.48550/arXiv.2512.04388

Sakana AI. (2026). *Sakana Fugu: Multi-agent system as a model*. https://sakana.ai/fugu/

Xu, J., Sun, Q., Schwendeman, P., Nielsen, S., Cetin, E., & Tang, Y. (2025). *TRINITY: An evolved LLM coordinator*. arXiv. https://doi.org/10.48550/arXiv.2512.04695
22 changes: 22 additions & 0 deletions docs/orchestrator-production.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,28 @@ endpoint is absent. That variable must never be set in staging or production.

## Orchestration responsibility

ScopeWeave explicitly sends `orchestration_mode: "auto"` together with the
configured model and validated messages on production briefing requests. The
current protected `ContextualWisdomLab/contextual-orchestrator` `main` contract
verified for this change, commit
`6841b71935e0b7cb98fb52bcb4709cc5100c8d87`, accepts `auto`, `route`, and
`conduct` as orchestration modes. ScopeWeave chooses `auto` as its default so
execution policy can be optimized centrally without coupling this product to a
specific provider, worker count, topology, verifier pattern, or cost heuristic.
Those internal choices remain `contextual-orchestrator` authority and are not a
ScopeWeave compatibility promise.

For authenticated project AI briefings, ScopeWeave also sends bounded business
cost attribution derived from server-side project state. `service=scopeweave`
and the authenticated project organization `account` are attached only after
membership-scoped project access succeeds. Caller payload fields cannot choose
another tenant's attribution. The client forwards only the orchestration
service's supported attribution dimensions, accepts only bounded string or
finite numeric values, holds validated labels in a prototype-free map, and
omits the attribution object entirely when no valid labels remain. Attribution
is accounting metadata only: it cannot select an execution provider, model, or
orchestration topology.

ScopeWeave intentionally sends only a versioned OpenAI-compatible request to
the orchestration service. Model selection, single-model versus multi-agent
allocation, task decomposition, role-specific reasoning effort, recursion
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@
"check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings",
"coverage": "npm run test:coverage",
"server": "node server/server.mjs",
"test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs",
"test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs",
"test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs",
"test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs",
"test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases",
"test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api",
"test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js",
Expand Down
5 changes: 4 additions & 1 deletion server/app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -995,7 +995,10 @@ app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => {
const analysis = await orchestratorChat([
{ role: 'system', content: '너는 공정관리(schedule control) 전문가다. 주어진 프로젝트 지표를 근거로 한국어 경영진 브리핑을 작성하라: ①일정 상태 한 줄 판정 ②핵심 리스크 2~3개(근거 지표 인용) ③실행 권고 2~3개. 지표에 없는 사실은 만들지 마라.' },
{ role: 'user', content: context },
]);
], {
service: 'scopeweave',
account: String(p.org_id),
});
logAudit(p.org_id, uid, 'ai.brief', 'project', p.id, { tasks: tasks.length });
return c.json({ analysis });
} catch (e) {
Expand Down
71 changes: 69 additions & 2 deletions server/orchestrator.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ const MAX_CONTENT_LENGTH = 100_000;
const MAX_PROVIDER_RESPONSE_BYTES = 1024 * 1024;
// WHATWG URL serializes an IPv6 hostname with brackets (`[::1]`).
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
const MAX_ATTRIBUTION_VALUE_LENGTH = 256;
const ATTRIBUTION_DIMENSIONS = new Set([
'account',
'service',
'upstream_api',
'model_name',
'team',
'group',
'company',
'provider',
]);

export const orchestratorMock = process.env.SCOPEWEAVE_DEV === '1' && !OC_URL;

Expand Down Expand Up @@ -137,6 +148,55 @@ function validatedMessages(messages) {
});
}

/**
* Copy optional cost-attribution labels into the exact orchestrator allowlist.
*
* Unknown dimensions and empty values are omitted rather than forwarded to the
* strict contextual-orchestrator request validator. Values must be strings or
* finite numeric identifiers before normalization to bounded strings; complex
* objects and non-finite numbers fail closed instead of becoming misleading
* labels through implicit JavaScript string coercion. Execution model/provider
* identity remains controlled by the top-level request model and the
* orchestrator's own provider routing evidence; this object is business
* cost-allocation metadata only.
*
* @param {unknown} attribution optional business cost-attribution mapping
* @returns {Record<string, string>|undefined} bounded allowed labels or undefined
*/
function sanitizedAttribution(attribution) {
if (attribution === undefined || attribution === null) return undefined;
if (typeof attribution !== 'object' || Array.isArray(attribution)) {
throw new OrchestratorConfigurationError(
'orchestrator_attribution_invalid',
'Orchestrator attribution must be an object when provided.',
);
}

const safe = Object.create(null);
for (const [key, value] of Object.entries(attribution)) {
if (!ATTRIBUTION_DIMENSIONS.has(key) || value === undefined || value === null) continue;
if (
typeof value !== 'string'
&& (typeof value !== 'number' || !Number.isFinite(value))
) {
throw new OrchestratorConfigurationError(
'orchestrator_attribution_invalid',
'Orchestrator attribution values must be strings or finite numbers.',
);
}
const text = String(value).trim();
if (!text) continue;
if (text.length > MAX_ATTRIBUTION_VALUE_LENGTH) {
throw new OrchestratorConfigurationError(
'orchestrator_attribution_invalid',
'Orchestrator attribution value is outside the accepted boundary.',
);
}
safe[key] = text;
}
return Object.keys(safe).length ? safe : undefined;
}

/**
* Build the stable response-size failure used by declared and streamed limits.
* @returns {OrchestratorConfigurationError} Operator-safe size error.
Expand Down Expand Up @@ -275,11 +335,13 @@ async function rejectProviderResponse(response) {
/**
* Generate one AI briefing through contextual-orchestrator.
* @param {unknown} messages OpenAI-compatible messages
* @param {unknown} [attribution] optional bounded business cost-attribution labels
* @returns {Promise<string>}
*/
export async function chat(messages) {
export async function chat(messages, attribution) {
const configuration = orchestratorConfiguration();
const safeMessages = validatedMessages(messages);
const safeAttribution = sanitizedAttribution(attribution);
if (configuration.mock) {
const user = safeMessages
.filter((message) => message.role === 'user')
Expand All @@ -303,7 +365,12 @@ export async function chat(messages) {
'content-type': 'application/json',
authorization: `Bearer ${configuration.token}`,
},
body: JSON.stringify({ model: OC_MODEL, messages: safeMessages }),
body: JSON.stringify({
model: OC_MODEL,
orchestration_mode: 'auto',
messages: safeMessages,
...(safeAttribution ? { attribution: safeAttribution } : {}),
}),
signal: AbortSignal.timeout(ORCHESTRATOR_TIMEOUT_MS),
});
} catch {
Expand Down
89 changes: 89 additions & 0 deletions tests/api/orchestrator-attribution.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import assert from 'node:assert/strict';

process.env.SCOPEWEAVE_DB = ':memory:';
process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef';
delete process.env.SCOPEWEAVE_DEV;
process.env.ORCHESTRATOR_URL = 'https://orchestrator.example';
process.env.ORCHESTRATOR_TOKEN = 'secret-token';
process.env.ORCHESTRATOR_MODEL = 'nvidia/nemotron-3-super-120b-a12b';

const providerCalls = [];
globalThis.fetch = async (url, init) => {
providerCalls.push({ url: String(url), init });
return new Response(JSON.stringify({
choices: [{ message: { content: 'Grounded production response' } }],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
};

const { app } = await import(`../../server/app.mjs?attribution-api-test=${Date.now()}`);

const jsonRequest = (path, options = {}) => app.request(path, {
...options,
headers: {
'content-type': 'application/json',
...(options.headers || {}),
},
});
const jsonBody = (value) => JSON.stringify(value);

async function createAccount(email) {
let response = await jsonRequest('/api/auth/signup', {
method: 'POST',
body: jsonBody({ email, password: 'password123', name: email }),
});
assert.equal(response.status, 200, `${email} signup`);
const token = (await response.json()).token;
const auth = { authorization: `Bearer ${token}` };
response = await jsonRequest('/api/me', { headers: auth });
assert.equal(response.status, 200, `${email} account lookup`);
const account = await response.json();
return { auth, orgId: account.orgs[0].id };
}

const owner = await createAccount('orchestrator-owner@scopeweave.test');
const outsider = await createAccount('orchestrator-outsider@scopeweave.test');

let response = await jsonRequest('/api/projects', {
method: 'POST',
headers: owner.auth,
body: jsonBody({ name: 'Attribution Project' }),
});
assert.equal(response.status, 200, 'owner creates attribution project');
const projectId = (await response.json()).id;

response = await jsonRequest(`/api/projects/${projectId}/ai/brief`, {
method: 'POST',
headers: owner.auth,
body: jsonBody({ account: String(outsider.orgId), service: 'spoofed-client-service' }),
});
assert.equal(response.status, 200, 'authorized owner receives AI briefing');
assert.equal(providerCalls.length, 1, 'authorized briefing performs one provider call');
assert.equal(providerCalls[0].url, 'https://orchestrator.example/v1/chat/completions');
const providerBody = JSON.parse(providerCalls[0].init.body);
assert.deepEqual(
providerBody.attribution,
{ service: 'scopeweave', account: String(owner.orgId) },
'the authenticated server-side project organization owns cost attribution',
);
assert.notEqual(
providerBody.attribution.account,
String(outsider.orgId),
'browser-supplied account data cannot spoof another tenant attribution',
);

response = await jsonRequest(`/api/projects/${projectId}/ai/brief`, {
method: 'POST',
headers: outsider.auth,
body: jsonBody({ account: String(owner.orgId) }),
});
assert.equal(response.status, 404, 'cross-tenant AI briefing hides project existence');
assert.equal(
providerCalls.length,
1,
'cross-tenant requests are rejected before any contextual-orchestrator call',
);

console.log('✓ AI briefing attribution tenant-boundary tests passed');
Loading
Loading